diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..96a144d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + BUN_VERSION: 1.3.10 + +jobs: + package-smoke: + name: Package smoke / ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + + strategy: + fail-fast: false + matrix: + include: + - name: linux-x64 + runner: ubuntu-24.04 + target: bun-linux-x64 + - name: darwin-arm64 + runner: macos-14 + target: bun-darwin-arm64 + - name: darwin-x64 + runner: macos-15-intel + target: bun-darwin-x64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Cache Bun install cache + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: Cache Cargo dependencies and native targets + uses: actions/cache@v4 + with: + path: | + ~/.cargo/git + ~/.cargo/registry + native/markdown-renderer-napi/target + native/openai-compat-ws-v2-napi/target + key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('native/**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-cargo- + + - name: Set up Rust + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build and smoke-test package + run: | + OUT_DIR="$RUNNER_TEMP/ncode-package-smoke" + bun build/packageSmoke.mjs \ + --build-mode external \ + --target "${{ matrix.target }}" \ + --out-dir "$OUT_DIR" \ + --keep-output diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8e37907 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,229 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + tag: + description: 'Existing release tag to publish, for example v0.2.0' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + cancel-in-progress: false + +env: + BUN_VERSION: 1.3.10 + +jobs: + preflight: + name: Release preflight + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Validate tag, version, changelog, and main ancestry + id: release + shell: bash + run: | + set -euo pipefail + + tag="${GITHUB_REF_NAME}" + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + tag="${{ inputs.tag }}" + git fetch --force origin "refs/tags/${tag}:refs/tags/${tag}" + git checkout --detach "refs/tags/${tag}" + fi + + if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Release tags must look like vX.Y.Z or vX.Y.Z-prerelease; got: $tag" >&2 + exit 1 + fi + + version="${tag#v}" + package_version="$(node -p "require('./package.json').version")" + if [[ "$package_version" != "$version" ]]; then + echo "Tag $tag does not match package.json version $package_version" >&2 + exit 1 + fi + + git fetch origin main --tags + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "Release tag $tag must point to a commit reachable from origin/main" >&2 + exit 1 + fi + + if ! grep -Fq "## [$version] - " CHANGELOG.md; then + echo "CHANGELOG.md must contain a released section for $version" >&2 + exit 1 + fi + + { + echo "tag=$tag" + echo "version=$version" + } >> "$GITHUB_OUTPUT" + + build: + name: Build / ${{ matrix.name }} + needs: preflight + runs-on: ${{ matrix.runner }} + timeout-minutes: 35 + permissions: + attestations: write + contents: read + id-token: write + + strategy: + fail-fast: false + matrix: + include: + - name: linux-x64 + runner: ubuntu-24.04 + target: bun-linux-x64 + - name: darwin-arm64 + runner: macos-14 + target: bun-darwin-arm64 + - name: darwin-x64 + runner: macos-15-intel + target: bun-darwin-x64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.preflight.outputs.tag }} + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Cache Bun install cache + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: Cache Cargo dependencies and native targets + uses: actions/cache@v4 + with: + path: | + ~/.cargo/git + ~/.cargo/registry + native/markdown-renderer-napi/target + native/openai-compat-ws-v2-napi/target + key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('native/**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-cargo- + + - name: Set up Rust + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build and smoke-test release package + shell: bash + run: | + set -euo pipefail + OUT_DIR="$RUNNER_TEMP/ncode-release" + bun build/packageSmoke.mjs \ + --build-mode external \ + --target "${{ matrix.target }}" \ + --out-dir "$OUT_DIR" \ + --keep-output + + version="${{ needs.preflight.outputs.version }}" + slug="${{ matrix.target }}" + slug="${slug#bun-}" + base="ncode-${version}-${slug}" + zip_path="$OUT_DIR/${base}.zip" + manifest_path="$OUT_DIR/${base}/manifest.json" + + test -f "$zip_path" + test -f "$manifest_path" + + shasum -a 256 "$zip_path" > "$OUT_DIR/${base}.zip.sha256" + cp "$manifest_path" "$OUT_DIR/${base}.manifest.json" + mkdir -p "$GITHUB_WORKSPACE/release-assets" + cp "$zip_path" "$OUT_DIR/${base}.zip.sha256" "$OUT_DIR/${base}.manifest.json" "$GITHUB_WORKSPACE/release-assets/" + + - name: Generate artifact attestations + uses: actions/attest@v4 + with: + subject-path: release-assets/* + + - name: Upload release assets + uses: actions/upload-artifact@v4 + with: + name: release-assets-${{ matrix.name }} + path: release-assets/* + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish GitHub release + needs: + - preflight + - build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.preflight.outputs.tag }} + + - name: Download release assets + uses: actions/download-artifact@v4 + with: + pattern: release-assets-* + path: release-assets + merge-multiple: true + + - name: Extract release notes from CHANGELOG.md + shell: bash + run: | + set -euo pipefail + version="${{ needs.preflight.outputs.version }}" + awk -v header="## [$version] - " ' + index($0, header) == 1 { inside=1; next } + inside && index($0, "## [") == 1 { exit } + inside { print } + ' CHANGELOG.md | sed '/^[[:space:]]*$/N;/^\n$/D' > release-notes.md + test -s release-notes.md + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + tag="${{ needs.preflight.outputs.tag }}" + gh release create "$tag" \ + release-assets/* \ + --verify-tag \ + --title "$tag" \ + --notes-file release-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf2459..cc2416c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ See [RELEASING.md](./RELEASING.md) for the release process and version-bump poli ### Added +- GitHub Actions now build, attest, and publish Linux and macOS release artifacts from version tags on `main`. - Load `AGENTS.md` and `.agents/` instructions into context via the `agentsmd` loader ([#15](https://github.com/Noumena-Network/code/pull/15)) - GLM 5.2 managed first-party model profile and tier routing ([#17](https://github.com/Noumena-Network/code/pull/17)) - GLM 5.2 promoted to the first-party default model ([#21](https://github.com/Noumena-Network/code/pull/21)) diff --git a/RELEASING.md b/RELEASING.md index b95f530..6c43a42 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -47,9 +47,19 @@ Build-only fixes that block user build (e.g. native module build failures across 5. Add a comparison link at the bottom of `CHANGELOG.md` for the new tag, e.g. `[0.2.0]: https://github.com/Noumena-Network/code/compare/v0.1.0...v0.2.0`. 6. Commit on a `release/VERSION` branch with the message `chore(release): vX.Y.Z`. 7. Open a PR. Do not tag or publish until the PR merges. -8. After merge, tag `vX.Y.Z` on the merge commit on `main` and cut the GitHub release. The release notes pull from the `## [VERSION]` section verbatim. +8. After merge, create and push tag `vX.Y.Z` on the merge commit on `main`. The GitHub Actions release workflow validates the tag, builds Linux and macOS artifacts, and publishes the GitHub release. Release notes are pulled from the `## [VERSION]` section verbatim. -If a revert is needed between tag and publish, delete the tag, revert the release commit, and re-cut. +The release workflow currently publishes: + +- `ncode-VERSION-linux-x64.zip` from `ubuntu-24.04` (`bun-linux-x64`) +- `ncode-VERSION-darwin-arm64.zip` from `macos-14` (`bun-darwin-arm64`) +- `ncode-VERSION-darwin-x64.zip` from `macos-15-intel` (`bun-darwin-x64`) +- matching `.sha256` checksum files and `.manifest.json` files for each artifact +- GitHub artifact attestations for the release assets + +Tags must point to commits reachable from `origin/main`, must match `package.json` (`v${version}`), and must have a matching `CHANGELOG.md` release section. + +If a revert is needed between tag and publish, delete the tag, revert the release commit, and re-cut. If a published release is bad, create a new patch release rather than mutating the released asset in place. ## Pre-1.0 Expectations diff --git a/build/packageAudit.mjs b/build/packageAudit.mjs index e891770..0b35388 100644 --- a/build/packageAudit.mjs +++ b/build/packageAudit.mjs @@ -36,8 +36,10 @@ const STATIC_FORBIDDEN_SUBSTRINGS = [ ]; const ENV_DERIVED_FORBIDDEN_VALUE_VARS = [ - 'HOME', + 'ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', + 'NOUMENA_API_KEY', + 'OPENAI_API_KEY', ]; // Production defaults that are intentionally baked into the binary via @@ -50,6 +52,40 @@ const ALLOWED_ENV_DERIVED_VALUES = new Set([ 'Kimi 2.7 Coder', ]); + +function normalizeForbiddenPathValue(value) { + const normalized = path.resolve(value); + return normalized.length >= 8 ? normalized : null; +} + +function collectLocalPathForbiddenSubstrings() { + const candidates = [ + { label: 'current checkout path', value: process.cwd() }, + { label: 'github workspace path', value: process.env.GITHUB_WORKSPACE }, + ]; + const seen = new Set(); + const patterns = []; + + for (const candidate of candidates) { + if (!candidate.value) continue; + const normalized = normalizeForbiddenPathValue(candidate.value); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + patterns.push({ label: candidate.label, value: normalized }); + + const windowsValue = normalized.replaceAll('/', '\\'); + if (windowsValue !== normalized && !seen.has(windowsValue)) { + seen.add(windowsValue); + patterns.push({ + label: `${candidate.label} (windows separators)`, + value: windowsValue, + }); + } + } + + return patterns; +} + async function walkFiles(rootPath, currentPath = rootPath) { const entries = await readdir(currentPath, { withFileTypes: true }); const files = []; @@ -182,6 +218,7 @@ export async function auditCompiledPackage({ const forbiddenSubstrings = [ ...STATIC_FORBIDDEN_SUBSTRINGS, + ...collectLocalPathForbiddenSubstrings(), ...collectEnvDerivedForbiddenSubstrings(), ]; diff --git a/build/packageSmoke.mjs b/build/packageSmoke.mjs index d510579..dea7a85 100644 --- a/build/packageSmoke.mjs +++ b/build/packageSmoke.mjs @@ -25,6 +25,7 @@ function parseArgs(argv) { const args = { outDir: undefined, target: undefined, + buildMode: 'noumena', runBinaryChecks: true, runNativeProbe: true, keepOutput: false, @@ -37,6 +38,9 @@ function parseArgs(argv) { } else if (arg === '--target') { args.target = argv[index + 1]; index += 1; + } else if (arg === '--build-mode') { + args.buildMode = argv[index + 1] ?? args.buildMode; + index += 1; } else if (arg === '--no-run') { args.runBinaryChecks = false; args.runNativeProbe = false; @@ -116,7 +120,7 @@ async function main() { try { const result = await buildCompiledPackage({ outDir: tempRoot, - buildMode: 'noumena', + buildMode: args.buildMode, target: args.target, });