diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..93a62961 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,260 @@ +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 + source_version="$(node -p 'require("./package.json").version')" + if [[ "$source_version" != "$VERSION" ]]; then + echo "Stable releases require package.json version $VERSION; found $source_version." >&2 + exit 1 + fi + 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: 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 + + - 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 diff --git a/docs/development.md b/docs/development.md index ce55db2e..c43311d8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -95,3 +95,58 @@ 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 are different: `package.json` must already contain the stable +version being published. Land that normal version update on `main`, let CI pass, +then dispatch the Release workflow with the same version. This keeps the source +tree aligned with the latest stable release without creating version commits for +every beta or release candidate. + +### 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. diff --git a/package.json b/package.json index fb6fe590..202834dd 100644 --- a/package.json +++ b/package.json @@ -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": {