diff --git a/.github/workflows/build-engines.yml b/.github/workflows/build-engines.yml index fcb18e83..9ebdd1fb 100644 --- a/.github/workflows/build-engines.yml +++ b/.github/workflows/build-engines.yml @@ -185,10 +185,12 @@ jobs: with: path: engines key: engines-${{ matrix.target.platform }}-${{ needs.generate.outputs.flags }}-${{ needs.generate.outputs.key }} + # publish-npm ships these binaries from the release's CI run, whenever the draft is published - uses: actions/upload-artifact@v4 with: name: engines-${{ matrix.target.platform }} path: engines + retention-days: 90 if-no-files-found: error build-macos: @@ -248,4 +250,4 @@ jobs: path: engines key: engines-${{ matrix.target.platform }}-${{ needs.generate.outputs.flags }}-${{ needs.generate.outputs.key }} - uses: actions/upload-artifact@v4 - with: { name: 'engines-${{ matrix.target.platform }}', path: engines, if-no-files-found: error } + with: { name: 'engines-${{ matrix.target.platform }}', path: engines, retention-days: 90, if-no-files-found: error } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4eba024..e0920025 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,20 @@ jobs: echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=false" >> "$GITHUB_OUTPUT" echo "push to dev: suites run, platform engines wait for main"; exit 0 fi + # A push to main is a release when its version has no tag yet: that commit, and only that one, + # builds every platform and runs the five-platform e2e, and release.yml drafts from it once + # it is green. A push whose version is already tagged released nothing new and builds nothing. + if [ "${{ github.event_name }}" = push ] && [ "${{ github.ref }}" = refs/heads/main ]; then + v="$(node .github/scripts/version.mjs get)" + if git ls-remote --exit-code --tags origin "refs/tags/v$v" >/dev/null; then + echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=false" >> "$GITHUB_OUTPUT" + echo "push to main at v$v, already tagged: suites run, nothing to release" + else + echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=true" >> "$GITHUB_OUTPUT" + echo "push to main at v$v, not yet tagged: the release build runs on every platform" + fi + exit 0 + fi if [ "${{ github.event_name }}" != pull_request ]; then echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=true" >> "$GITHUB_OUTPUT" echo "${{ github.event_name }} on ${{ github.ref }}: everything runs"; exit 0 @@ -98,8 +112,9 @@ jobs: -e '\.dl$' -e '^graph/pipeline/' -e '^packaging/' -e 'scripts/dl_program\.py$' -e '^\.github/scripts/query-smoke\.sh$' \ -e '^\.github/workflows/build-engines\.yml$' -e '^package\.json$' -e '^\.github/scripts/e2e-' -e '^\.github/e2e/' || true)" [ -n "$code" ] && echo "code=true" >> "$GITHUB_OUTPUT" || echo "code=false" >> "$GITHUB_OUTPUT" - # Only a pull request INTO main builds the platform engines; into dev they wait. - [ "${{ github.base_ref }}" = main ] || engines="" + # No pull request builds the platform engines: a release builds them once, on the push that + # lands it on main, and publishes exactly that build (#1350). + engines="" [ -n "$engines" ] && echo "engines=true" >> "$GITHUB_OUTPUT" || echo "engines=false" >> "$GITHUB_OUTPUT" echo "suites: $([ -n "$code" ] && echo run || echo skip) platform engines: $([ -n "$engines" ] && echo run || echo skip)" @@ -206,6 +221,25 @@ jobs: if: github.event_name == 'pull_request' run: bash .github/scripts/version-gate.sh "origin/${{ github.base_ref }}" + # A push to main builds and releases only when its version is new (#1350). The gate above already + # refuses a pull request that changes what users get without a new version; a CI or docs change + # may keep main's version and then builds nothing when it lands. What is left to refuse here is a + # new version that was already released: its tag exists, so the push would build nothing and the + # change would never ship. + - name: a pull request into main does not reuse a released version + if: github.event_name == 'pull_request' && github.base_ref == 'main' + run: | + set -euo pipefail + head="$(node .github/scripts/version.mjs get)" + base="$(git show origin/main:package.json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).version))')" + if [ "$head" = "$base" ]; then + echo "main stays at $base: nothing in this change is released, and landing it builds nothing"; exit 0 + fi + if git ls-remote --exit-code --tags origin "refs/tags/v$head" >/dev/null; then + echo "::error::v$head is already released — pick the next version"; exit 1 + fi + echo "main $base -> $head: landing this builds every platform and drafts v$head" + engine: name: engine (${{ matrix.lang }}) needs: [changes] @@ -385,10 +419,9 @@ jobs: AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache run: bash .github/scripts/run-suite.sh ${{ matrix.lang }} ${{ matrix.oracle }} - # Every language's engine, every platform: the reusable build that publish-npm - # ships from, run here WITHOUT publishing. A rule that solves on Ubuntu but does - # not compile with MSVC, or that no longer generates for a language, fails the - # gate here rather than at release time. + # Every language's engine, every platform, built once per release: on the push that + # lands a new version on main (and in the nightly). publish-npm ships these very + # artifacts, so what the e2e below tested is what users install (#1350). engines: name: engines build on every platform needs: [build, changes] @@ -401,7 +434,7 @@ jobs: # job only proves each binary compiles and starts; the suites run from the checkout. Neither # installs the packages, so a missing `files` entry, an engine package the CLI does not find, # a query program that needs Soufflé, or a verb that only breaks on Windows reached users. - # Runs wherever the platform engines are built: on the way into main, and in the nightly. + # Runs wherever the platform engines are built: on the push that lands a release on main, and in the nightly. pack: name: pack @axiomcode/code-graph needs: [build, changes] @@ -416,8 +449,9 @@ jobs: - run: npm install --no-audit --no-fund # --ignore-scripts: `prepare` already built it; the tarball carries what the build produced - run: mkdir -p tgz && npm pack --ignore-scripts --pack-destination tgz && ls -la tgz + # kept as long as the engines: publish-npm ships this exact tarball, whenever the draft is published - uses: actions/upload-artifact@v4 - with: { name: code-graph-tgz, path: tgz, retention-days: 3, if-no-files-found: error } + with: { name: code-graph-tgz, path: tgz, retention-days: 90, if-no-files-found: error } e2e: name: e2e on ${{ matrix.target.platform }} diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 99625a60..25ce21ec 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -67,17 +67,16 @@ jobs: echo "dist_tag=$dist_tag" >> "$GITHUB_OUTPUT" echo "publishing $version as '$dist_tag'" - engines: - needs: check - uses: ./.github/workflows/build-engines.yml - with: - fresh: true # what ships is compiled from scratch, never restored - + # Nothing is compiled here. The push that landed this version on main built every platform's + # engines, packed @axiomcode/code-graph and ran the five-platform e2e on exactly those files; + # release.yml drafted this release only after that run was green. Publishing ships that build, + # so what was tested is what users install, and a release costs one build, not three (#1350). publish: - needs: [check, engines] + needs: [check] runs-on: ubuntu-24.04 permissions: contents: write # attach the tarballs to the release + actions: read # the release's CI run and its artifacts env: VERSION: ${{ needs.check.outputs.version }} DIST_TAG: ${{ needs.check.outputs.dist_tag }} @@ -88,8 +87,33 @@ jobs: with: node-version: '22' registry-url: 'https://registry.npmjs.org' + - name: the green CI run that built and tested this commit + id: run + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + sha="$(git rev-parse HEAD)" + run="$(gh run list --workflow ci.yml --commit "$sha" --event push --branch main --status success \ + --limit 1 --json databaseId --jq '.[0].databaseId // empty')" + if [ -z "$run" ]; then + echo "::error::no green CI run of a push to main for ${sha::8}: nothing built and tested this commit, so nothing is published" + exit 1 + fi + echo "id=$run" >> "$GITHUB_OUTPUT" + echo "shipping the build of CI run $run ($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$run)" - uses: actions/download-artifact@v4 - with: { pattern: engines-*, path: artifacts } + with: + pattern: engines-* + path: artifacts + run-id: ${{ steps.run.outputs.id }} + github-token: ${{ github.token }} + - uses: actions/download-artifact@v4 + with: + name: code-graph-tgz + path: artifacts/tgz + run-id: ${{ steps.run.outputs.id }} + github-token: ${{ github.token }} # A real release ships every platform the root package pins. Missing one # means some machine installs a version whose engine does not exist. @@ -126,10 +150,13 @@ jobs: cat "packages/engine-$platform/package.json" done - # `prepare` builds the parser and the driver, so the packed tarball holds - # parser/dist and dist exactly as a user installs them. - - name: build @axiomcode/code-graph - run: npm install --no-audit --no-fund + - name: the tested @axiomcode/code-graph tarball is this version + run: | + set -euo pipefail + tgz="$(ls artifacts/tgz/*.tgz)" + got="$(tar -xOzf "$tgz" package/package.json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).version))')" + [ "$got" = "$VERSION" ] || { echo "::error::the tested tarball is $got, this release is $VERSION"; exit 1; } + echo "TGZ=$tgz" >> "$GITHUB_ENV" - name: publish (or dry-run) env: @@ -150,7 +177,14 @@ jobs: && npm publish --access public --tag "$DIST_TAG" $flag ) } for p in packages/engine-*; do publish "$p"; done - publish . + # the tarball the e2e installed, published as it is rather than packed again + if [ -z "$flag" ] && npm view "@axiomcode/code-graph@$VERSION" version >/dev/null 2>&1; then + echo "══ @axiomcode/code-graph@$VERSION is already on the registry — skipped" + else + echo "══ @axiomcode/code-graph@$VERSION tag=$DIST_TAG $flag" + cp "$TGZ" tarballs/ + npm publish "$TGZ" --access public --tag "$DIST_TAG" $flag + fi ls -la tarballs if [ "$DRY" = true ]; then echo "DRY RUN — nothing was uploaded." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72de687a..3da821a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,23 +2,26 @@ # Every version that lands on main gets a tag and a draft release, and dev is # brought up to date with main (the sync-dev job at the end). # -# The version gate (ci.yml) makes a pull request that reaches users bump the -# version. When that bump merges, this workflow tags the merge commit v -# and opens a DRAFT GitHub release with notes generated from the pull requests -# since the previous tag. Nothing is published here. +# A push to main whose version has no tag yet is a release. CI on that push builds +# the engines on all five platforms and runs the five-platform e2e; only when that +# run is GREEN does this workflow tag the commit it tested v and open a +# DRAFT GitHub release with notes generated +# from the pull requests since the previous tag. Nothing is published here. # # Publishing is a person's decision: review the draft, then press Publish. That -# `release: published` event is what runs publish-npm.yml, which builds the -# engines, checks that the tag and every manifest agree, publishes to npm and -# attaches the tarballs to the release. +# `release: published` event runs publish-npm.yml, which ships the binaries and +# the tarball of that same green CI run: nothing is compiled twice (#1350). # -# A push that does not move the version finds its tag already present and does -# nothing, so this runs on every push to main without a path filter. +# A version that is already tagged finds its tag present and does nothing. # ───────────────────────────────────────────────────────────────────────────── name: release on: push: + branches: [main] # sync-dev + workflow_run: # tag: after CI on main finished + workflows: [CI] + types: [completed] branches: [main] workflow_dispatch: @@ -31,10 +34,18 @@ permissions: jobs: tag: + # the CI run of a PUSH to main, and only a green one: a red release build drafts nothing + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'push' && + github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-24.04 + env: + SHA: ${{ github.event.workflow_run.head_sha || github.sha }} steps: - uses: actions/checkout@v4 with: + ref: ${{ env.SHA }} fetch-depth: 0 - uses: actions/setup-node@v4 @@ -67,7 +78,7 @@ jobs: # a person to publish the draft. git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag -a "$tag" -m "$tag" "$GITHUB_SHA" + git tag -a "$tag" -m "$tag" "$SHA" git push origin "refs/tags/$tag" gh release create "$tag" --draft --verify-tag --title "$tag" \ --generate-notes $start $prerelease @@ -81,6 +92,7 @@ jobs: # that went straight to main and touches lines dev has since changed does not, # and then nothing is pushed: an issue says how to resolve it by hand. sync-dev: + if: github.event_name == 'push' runs-on: ubuntu-24.04 permissions: contents: write