Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
300 changes: 300 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
name: Release

on:
workflow_dispatch:
inputs:
version:
description: Version to publish
required: true
type: string

permissions:
actions: read
contents: write
id-token: write

concurrency:
group: release
cancel-in-progress: false

jobs:
publish:
name: Publish ${{ inputs.version }}
runs-on: ubuntu-latest
timeout-minutes: 20
env:
DEVSPACE_OAUTH_OWNER_TOKEN: ci-owner-token-that-is-long-enough
VERSION: ${{ inputs.version }}
steps:
- name: Checkout release source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
persist-credentials: false

- name: Require main
run: |
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
echo "Releases must be dispatched from main; got $GITHUB_REF" >&2
exit 1
fi

- name: Require successful main CI
env:
GH_TOKEN: ${{ github.token }}
run: |
state="$(gh run list \
--workflow ci.yml \
--commit "$GITHUB_SHA" \
--event push \
--limit 1 \
--json status,conclusion \
--jq '.[0] | "\(.status):\(.conclusion)"')"
if [[ "$state" != "completed:success" ]]; then
echo "The exact release commit must have a successful main CI push run; got ${state:-no run}." >&2
exit 1
fi

- name: Resolve release metadata
id: release
run: |
stable='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
prerelease='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-(beta|rc)\.([1-9][0-9]*)$'

if [[ "$VERSION" =~ $stable ]]; then
npm_tag=latest
is_prerelease=false
Comment thread
Waishnav marked this conversation as resolved.
elif [[ "$VERSION" =~ $prerelease ]]; then
npm_tag=beta
is_prerelease=true
else
echo "Unsupported release version: $VERSION" >&2
echo "Use X.Y.Z-beta.N, X.Y.Z-rc.N, or X.Y.Z." >&2
exit 1
fi

echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
echo "npm_tag=$npm_tag" >> "$GITHUB_OUTPUT"
echo "prerelease=$is_prerelease" >> "$GITHUB_OUTPUT"

- name: Verify release tag ownership
env:
TAG: ${{ steps.release.outputs.tag }}
run: |
if git show-ref --verify --quiet "refs/tags/$TAG"; then
tag_sha="$(git rev-parse "$TAG^{commit}")"
if [[ "$tag_sha" != "$GITHUB_SHA" ]]; then
echo "$TAG already points to $tag_sha instead of $GITHUB_SHA." >&2
exit 1
fi
fi

- name: Setup pnpm and Node
uses: pnpm/setup@84cb39b217b10273981911c288cd62326dc7c6d2 # v2
with:
runtime: node@22
cache: false
install: false

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Validate stable version progression
if: steps.release.outputs.prerelease != 'true'
run: |
package_name="$(node -p 'require("./package.json").name')"
source_version="$(node -p 'require("./package.json").version')"
latest_version="$(npm view "$package_name" dist-tags.latest)"
node --input-type=module - "$VERSION" "$source_version" "$latest_version" <<'NODE'
import semver from "semver"

const [candidate, source, latest] = process.argv.slice(2)
if (!semver.valid(source) || !semver.valid(latest)) {
console.error(`Stable release baseline is invalid: source=${source}, npm@latest=${latest}`)
process.exit(1)
}
if (semver.lt(candidate, source) || semver.lt(candidate, latest)) {
console.error(`Stable release ${candidate} would regress source ${source} or npm@latest ${latest}`)
process.exit(1)
}
NODE

- name: Set package version
run: |
npm pkg set version="$VERSION"
node -e 'const pkg = require("./package.json"); if (pkg.version !== process.env.VERSION) process.exit(1)'

- name: Typecheck
run: pnpm typecheck

- name: Test
env:
DEVSPACE_REQUIRE_PI_SANDBOX: "0"
run: pnpm test

- name: Package install smoke test
run: pnpm test:package-install

- name: Pack release artifact
id: pack
run: |
mkdir -p .release
filename="$(npm pack --silent --pack-destination .release | tail -n 1)"
test -n "$filename"
tarball=".release/$filename"
integrity="sha512-$(openssl dgst -sha512 -binary "$tarball" | openssl base64 -A)"
echo "filename=$filename" >> "$GITHUB_OUTPUT"
echo "tarball=$tarball" >> "$GITHUB_OUTPUT"
echo "integrity=$integrity" >> "$GITHUB_OUTPUT"

- name: Inspect existing publication
id: publication
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.release.outputs.tag }}
TARBALL: ${{ steps.pack.outputs.tarball }}
FILENAME: ${{ steps.pack.outputs.filename }}
LOCAL_INTEGRITY: ${{ steps.pack.outputs.integrity }}
IS_PRERELEASE: ${{ steps.release.outputs.prerelease }}
NPM_TAG: ${{ steps.release.outputs.npm_tag }}
run: |
package_name="$(node -p 'require("./package.json").name')"
npm_exists=false
if npm_integrity="$(npm view "$package_name@$VERSION" dist.integrity 2>/dev/null)"; then
if [[ -z "$npm_integrity" || "$npm_integrity" != "$LOCAL_INTEGRITY" ]]; then
echo "$package_name@$VERSION already exists on npm with different integrity." >&2
exit 1
fi
published_channel="$(npm view "$package_name" "dist-tags.$NPM_TAG" 2>/dev/null || true)"
if [[ "$published_channel" != "$VERSION" ]]; then
echo "$package_name@$VERSION exists on npm but @$NPM_TAG points to ${published_channel:-nothing}." >&2
exit 1
fi
npm_exists=true
fi

release_exists=false
release_draft=false
complete=false
if release_json="$(gh release view "$TAG" --json isDraft,isPrerelease,targetCommitish 2>/dev/null)"; then
release_exists=true
release_draft="$(jq -r .isDraft <<<"$release_json")"
release_prerelease="$(jq -r .isPrerelease <<<"$release_json")"
release_target="$(jq -r .targetCommitish <<<"$release_json")"
if [[ "$release_target" != "$GITHUB_SHA" ]]; then
echo "$TAG already targets $release_target instead of $GITHUB_SHA." >&2
exit 1
fi
if [[ "$release_draft" != "true" ]]; then
if [[ "$release_prerelease" != "$IS_PRERELEASE" ]]; then
echo "$TAG is already public with the wrong prerelease state." >&2
exit 1
fi
if [[ "$npm_exists" != "true" ]]; then
echo "$TAG is already public on GitHub but $package_name@$VERSION is missing from npm." >&2
exit 1
fi

mkdir -p .release/existing
if ! gh release download "$TAG" --pattern "$FILENAME" --dir .release/existing --clobber; then
echo "$TAG is already public but its release artifact could not be verified." >&2
exit 1
fi
if ! cmp --silent "$TARBALL" ".release/existing/$FILENAME"; then
echo "$TAG is already public with a different release artifact." >&2
exit 1
fi
complete=true
fi
fi

echo "npm_exists=$npm_exists" >> "$GITHUB_OUTPUT"
echo "release_exists=$release_exists" >> "$GITHUB_OUTPUT"
echo "release_draft=$release_draft" >> "$GITHUB_OUTPUT"
echo "complete=$complete" >> "$GITHUB_OUTPUT"

- name: Prepare draft GitHub release
if: steps.publication.outputs.complete != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.release.outputs.tag }}
IS_PRERELEASE: ${{ steps.release.outputs.prerelease }}
RELEASE_EXISTS: ${{ steps.publication.outputs.release_exists }}
run: |
if [[ "$RELEASE_EXISTS" == "true" ]]; then
echo "Reusing existing draft GitHub release $TAG."
else
args=(release create "$TAG" --draft --title "$TAG" --target "$GITHUB_SHA" --generate-notes)
if [[ "$IS_PRERELEASE" == "true" ]]; then
args+=(--prerelease)
fi
gh "${args[@]}"
fi

- name: Upload release artifact
if: steps.publication.outputs.complete != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.release.outputs.tag }}
TARBALL: ${{ steps.pack.outputs.tarball }}
run: gh release upload "$TAG" "$TARBALL" --clobber

- name: Setup npm trusted publishing
if: steps.publication.outputs.complete != 'true' && steps.publication.outputs.npm_exists != 'true'
run: |
npm install --global npm@11.8.0
npm config set registry https://registry.npmjs.org/
node_version="$(node -p 'process.versions.node')"
npm_version="$(npm --version)"
echo "Node.js $node_version"
echo "npm $npm_version"
node -e 'const [major, minor] = process.versions.node.split(".").map(Number); if (major < 22 || (major === 22 && minor < 14)) process.exit(1)'
if [[ "$npm_version" != "11.8.0" ]]; then
echo "Expected npm 11.8.0 for trusted publishing; resolved npm $npm_version." >&2
exit 1
fi

- name: Publish npm package
if: steps.publication.outputs.complete != 'true' && steps.publication.outputs.npm_exists != 'true'
env:
NPM_TAG: ${{ steps.release.outputs.npm_tag }}
TARBALL: ${{ steps.pack.outputs.tarball }}
run: npm publish "$TARBALL" --access public --tag "$NPM_TAG" --provenance
Comment thread
Waishnav marked this conversation as resolved.

- name: Publish GitHub release
if: steps.publication.outputs.complete != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.release.outputs.tag }}
IS_PRERELEASE: ${{ steps.release.outputs.prerelease }}
run: |
if [[ "$IS_PRERELEASE" == "true" ]]; then
gh release edit "$TAG" --draft=false --prerelease
else
gh release edit "$TAG" --draft=false --prerelease=false --latest
fi

- name: Sync stable version to main
if: steps.release.outputs.prerelease != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
git fetch origin main
remote_main="$(git rev-parse origin/main)"
remote_version="$(git show origin/main:package.json | jq -r .version)"
if [[ "$remote_version" == "$VERSION" ]]; then
echo "package.json on main already records $VERSION; no sync commit needed."
exit 0
fi
if [[ "$remote_main" != "$GITHUB_SHA" ]]; then
echo "main moved from $GITHUB_SHA to $remote_main during the release; refusing to push a version sync commit." >&2
exit 1
Comment thread
Waishnav marked this conversation as resolved.
fi

npm pkg set version="$VERSION"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json
git commit -m "chore(release): prepare v$VERSION"
gh auth setup-git
git push origin HEAD:main
58 changes: 58 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,61 @@ pnpm typecheck
pnpm test
pnpm build
```

## Releases

Releases are published by the manual `Release` GitHub Actions workflow. Do not
publish the package directly from a development checkout for normal releases.
The workflow only accepts runs dispatched from `main`, and the exact commit must
already have a successful `CI` push run.

Prereleases use the `beta` npm dist-tag. Both beta and release-candidate
versions follow the same install channel:

```text
1.1.0-beta.1 -> @beta
1.1.0-beta.2 -> @beta
1.1.0-rc.1 -> @beta
1.1.0 -> @latest
```

Run the workflow from GitHub Actions and enter the version without a leading
`v`, for example `1.1.0-beta.1`. The workflow temporarily writes prerelease
versions into `package.json`, validates and packs that exact source commit,
publishes the resulting tarball to npm, and then publishes the matching GitHub
release. Prerelease version changes are not committed back to `main`.

Stable releases use the same temporary version change while building and
publishing. After npm and GitHub publication both succeed, the workflow commits
the released version back to `main` as `chore(release): prepare vX.Y.Z`. If
`main` moved while the release was running, the version sync fails instead of
overwriting concurrent work. This keeps the source tree aligned with the latest
stable release without creating version commits for every beta or release
candidate. A stable release is rejected if its version is lower than either the
source version or npm's current `latest` version.

### npm trusted publishing setup

The release workflow authenticates to npm through GitHub Actions OIDC instead of
a long-lived npm token. Configure `@waishnav/devspace` on npm with a GitHub
Actions trusted publisher using:

- repository owner: `Waishnav`
- repository: `devspace`
- workflow filename: `release.yml`
- no GitHub environment
- allow direct `npm publish`

The workflow uses a GitHub-hosted runner, requests `id-token: write`, and pins an
npm CLI new enough for trusted publishing. Its package artifact is also attached
to a draft GitHub release before npm publication; the GitHub release is made
public only after npm succeeds.

Re-running the same version is safe only when it still identifies the same
artifact and release channel. Existing npm versions must have the same package
integrity as the newly packed tarball and already be assigned to the requested
`beta` or `latest` dist-tag. Existing draft GitHub releases may be resumed, but
public releases are never modified: the workflow verifies npm state and the
published GitHub tarball and exits successfully only when they already match.
Any partial or mismatched public release fails for manual investigation instead
of rewriting published state.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
"name": "@waishnav/devspace",
"version": "1.0.8",
"description": "Expose a secure local coding workspace through an MCP server.",
"repository": {
"type": "git",
"url": "git+https://github.com/Waishnav/devspace.git"
},
"type": "module",
"main": "dist/server.js",
"engines": {
Expand Down
Loading