diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f09bfb..74f21ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ permissions: contents: read env: - SHOULD_RELEASE: ${{ github.event_name == 'push' && (github.ref_name == 'dev' || startsWith(github.ref, 'refs/tags/v')) }} + SHOULD_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} jobs: test: @@ -43,7 +43,7 @@ jobs: path: package/ego-browser/dist/ego-browser-release-payload.tar.gz release: - if: github.event_name == 'push' && (github.ref_name == 'dev' || startsWith(github.ref, 'refs/tags/v')) + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') needs: test runs-on: ubuntu-latest permissions: @@ -68,29 +68,10 @@ jobs: id: release run: | ref_name="${GITHUB_REF_NAME}" - short_sha="$(git rev-parse --short HEAD)" supported="true" previous_tag="" - if [ "$ref_name" = "dev" ]; then - latest_beta="$(git tag --sort=-v:refname --list 'v*.*.*-beta.*' | head -1 || true)" - latest_stable="$(git tag --sort=-v:refname --list 'v*.*.*' --list '[0-9]*.[0-9]*' | grep -E '^(v?[0-9]+\.[0-9]+\.[0-9]+|[0-9]+\.[0-9]+)$' | head -1 || true)" - - if [ -n "$latest_beta" ]; then - base_version="${latest_beta%%-beta.*}" - elif [[ "$latest_stable" =~ ^v?([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - base_version="v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))" - elif [[ "$latest_stable" =~ ^([0-9]+)\.([0-9]+)$ ]]; then - base_version="v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.1" - else - base_version="v0.1.0" - fi - - release_tag="$base_version-nightly.$(date -u +%Y%m%d).$short_sha" - prerelease="true" - latest="false" - previous_tag="$(git tag --merged HEAD --sort=-creatordate --list 'v*.*.*-nightly.*' | grep -v "^$release_tag$" | head -1 || true)" - elif [[ "$ref_name" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then + if [[ "$ref_name" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then release_tag="$ref_name" prerelease="true" latest="false" @@ -135,10 +116,7 @@ jobs: release_args=() notes_args=() - if [[ "$release_tag" == v*.*.*-nightly.* ]]; then - git tag -f "$release_tag" "$commit_sha" - git push -f origin "refs/tags/$release_tag" - elif [ "$prerelease" = "false" ] && ! git merge-base --is-ancestor "$commit_sha" origin/main; then + if [ "$prerelease" = "false" ] && ! git merge-base --is-ancestor "$commit_sha" origin/main; then echo "Stable release tags must point to commits reachable from main." exit 1 fi diff --git a/.github/workflows/main-pr-source.yml b/.github/workflows/main-pr-source.yml index e9002c8..97b7e8b 100644 --- a/.github/workflows/main-pr-source.yml +++ b/.github/workflows/main-pr-source.yml @@ -10,8 +10,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Require dev branch + env: + HEAD_REF: ${{ github.head_ref }} run: | - if [ "${{ github.head_ref }}" != "dev" ]; then + if [ "$HEAD_REF" != "dev" ]; then echo "Pull requests into main must come from the dev branch." exit 1 fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1bcee5..6dbdea3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -211,12 +211,11 @@ E2E suite is a local gate and is not run by this GitHub-hosted workflow. The current release behavior is: -| Trigger | Result | -| ------------------------------------ | --------------------------------------------------------------------------------------------------- | -| Push to `dev` | Publishes a nightly prerelease named `vX.Y.Z-nightly.YYYYMMDD.SHA`. | -| Push a `vX.Y.Z-beta.N` tag | Creates a beta prerelease **Draft**. | -| Push a `vX.Y.Z` tag | Publishes a stable release and marks it as Latest. The tagged commit must be reachable from `main`. | -| Push to `main` without a version tag | Runs CI without creating a release. | +| Trigger | Result | +| --------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Push a `vX.Y.Z-beta.N` tag | Creates a beta prerelease **Draft**. | +| Push a `vX.Y.Z` tag | Publishes a stable release and marks it as Latest. The tagged commit must be reachable from `main`. | +| Push to `dev` or `main` without a version tag | Runs CI without creating a release. | Release jobs reuse the tested build and package `dist/out/` into `ego-browser-.zip`. Notes are generated from merged PRs using diff --git a/package/ego-browser/src/workflows.test.mjs b/package/ego-browser/src/workflows.test.mjs new file mode 100644 index 0000000..f06115b --- /dev/null +++ b/package/ego-browser/src/workflows.test.mjs @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +function workflow(name) { + return readFileSync( + new URL(`../../../.github/workflows/${name}.yml`, import.meta.url), + "utf8", + ); +} + +function releaseGuards(github) { + const source = workflow("ci"); + const expressions = [ + source.match(/SHOULD_RELEASE: \$\{\{\s*(.*?)\s*\}\}/)?.[1], + source.match(/^ release:\n if: (.+)$/m)?.[1], + ]; + return expressions.map((expression) => { + assert.ok(expression, "release guard must be present"); + // These workflow guards use the shared JS/GitHub expression subset. + return new Function("github", "startsWith", `return (${expression});`)( + github, + (value, prefix) => value.toLowerCase().startsWith(prefix.toLowerCase()), + ); + }); +} + +test("branch pushes and pull requests do not package or publish releases", () => { + for (const branch of ["dev", "main", "2.0.0-beta-dev"]) { + assert.deepEqual( + releaseGuards({ + event_name: "push", + ref: `refs/heads/${branch}`, + ref_name: branch, + }), + [false, false], + branch, + ); + } + assert.deepEqual( + releaseGuards({ + event_name: "pull_request", + ref: "refs/pull/1/merge", + ref_name: "1/merge", + }), + [false, false], + ); +}); + +test("beta and stable tag pushes retain both release guards", () => { + for (const tag of ["v2.0.0-beta.9", "v2.0.0"]) { + assert.deepEqual( + releaseGuards({ + event_name: "push", + ref: `refs/tags/${tag}`, + ref_name: tag, + }), + [true, true], + tag, + ); + } +}); + +function runSourceGuard(branch) { + const source = workflow("main-pr-source"); + const script = source.split(" run: |\n")[1]; + assert.ok(script, "source guard must have a shell step"); + const env = { ...process.env }; + delete env.HEAD_REF; + for (const match of source.matchAll( + /^ (\w+): \$\{\{ github\.head_ref \}\}$/gm, + )) { + env[match[1]] = branch; + } + return spawnSync( + "bash", + ["-e", "-c", script.replaceAll("${{ github.head_ref }}", branch)], + { env, encoding: "utf8", timeout: 5000 }, + ); +} + +test("main source guard allows dev and rejects other branches", () => { + assert.equal(runSourceGuard("dev").status, 0); + assert.equal(runSourceGuard("feature/change").status, 1); +}); + +test("main source guard treats shell syntax in branch names as data", () => { + const directory = mkdtempSync(join(tmpdir(), "ego-branch-name-")); + const marker = join(directory, "unexpected-command"); + try { + const result = runSourceGuard(`feature/$(touch\${IFS}${marker})`); + assert.equal(result.status, 1); + assert.equal(existsSync(marker), false, "branch name executed a command"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +});