diff --git a/.github/actions/check-ci-status/action.yml b/.github/actions/check-ci-status/action.yml new file mode 100644 index 00000000..3e70fa09 --- /dev/null +++ b/.github/actions/check-ci-status/action.yml @@ -0,0 +1,45 @@ +name: Check CI Status +description: Verifies that CI checks passed for a candidate commit before promotion. + +inputs: + commit-sha: + description: Commit SHA to check. + required: true + token: + description: GitHub token with repository read access. + required: true + required-checks: + description: Comma-separated checks that must have succeeded. + default: '' + +runs: + using: composite + steps: + - name: Verify CI checks passed + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + COMMIT_SHA: ${{ inputs.commit-sha }} + REQUIRED_CHECKS: ${{ inputs.required-checks }} + REPO: ${{ github.repository }} + run: | + CHECK_RUNS=$(gh api "repos/$REPO/commits/$COMMIT_SHA/check-runs" --paginate \ + --jq '.check_runs[] | {name: .name, status: .status, conclusion: .conclusion}') + if [ -z "$CHECK_RUNS" ]; then + echo "No check-runs found for $COMMIT_SHA" + exit 1 + fi + + FAILED=0 + IFS=',' read -ra CHECKS <<< "$REQUIRED_CHECKS" + for CHECK in "${CHECKS[@]}"; do + CHECK=$(echo "$CHECK" | xargs) + [ -z "$CHECK" ] && continue + MATCH=$(echo "$CHECK_RUNS" | jq -c --arg name "$CHECK" 'select(.name == $name)' | head -1) + if [ -z "$MATCH" ] || [ "$(echo "$MATCH" | jq -r '.status')" != 'completed' ] || [ "$(echo "$MATCH" | jq -r '.conclusion')" != 'success' ]; then + echo "Required check '$CHECK' did not succeed" + FAILED=1 + fi + done + + [ "$FAILED" -eq 0 ] || exit 1 diff --git a/.github/actions/npm-install-with-retries/action.yml b/.github/actions/npm-install-with-retries/action.yml new file mode 100644 index 00000000..6851f06b --- /dev/null +++ b/.github/actions/npm-install-with-retries/action.yml @@ -0,0 +1,18 @@ +name: pnpm-install-with-retries +description: Installs workspace dependencies with retry support for promotion workflows. + +inputs: + ignore-scripts: + description: Skip lifecycle scripts during installation. + default: 'false' + +runs: + using: composite + steps: + - name: Enable pnpm + shell: bash + run: corepack enable + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/retry@main + with: + command: pnpm install --frozen-lockfile ${{ inputs.ignore-scripts == 'true' && '--ignore-scripts' || '' }} diff --git a/.github/actions/publish-vsix/action.yml b/.github/actions/publish-vsix/action.yml new file mode 100644 index 00000000..fd294583 --- /dev/null +++ b/.github/actions/publish-vsix/action.yml @@ -0,0 +1,42 @@ +name: Publish VSIX +description: Publishes a VSIX to the selected public marketplace with dry-run support. + +inputs: + vsix-path: + description: Path to the VSIX file. + required: true + publish-tool: + description: 'Marketplace publisher: vsce or ovsx.' + required: true + pre-release: + description: Publish as a pre-release. + default: 'false' + dry-run: + description: Skip the live publish request. + default: 'false' + +runs: + using: composite + steps: + - name: Publish VSIX + shell: bash + env: + VSIX_PATH: ${{ inputs.vsix-path }} + PUBLISH_TOOL: ${{ inputs.publish-tool }} + PRE_RELEASE: ${{ inputs.pre-release }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + [ -f "$VSIX_PATH" ] || { echo "VSIX not found: $VSIX_PATH"; exit 1; } + case "$PUBLISH_TOOL" in vsce|ovsx) ;; *) echo "Unsupported publish tool: $PUBLISH_TOOL"; exit 1;; esac + [ "$DRY_RUN" = true ] && { echo "DRY RUN: Would publish $VSIX_PATH with $PUBLISH_TOOL"; exit 0; } + + PRE_RELEASE_FLAG='' + [ "$PRE_RELEASE" = true ] && PRE_RELEASE_FLAG='--pre-release' + if [ "$PUBLISH_TOOL" = vsce ]; then + [ -n "${VSCE_PERSONAL_ACCESS_TOKEN:-}" ] || { echo 'VSCE_PERSONAL_ACCESS_TOKEN is required'; exit 1; } + VSCE_PAT="$VSCE_PERSONAL_ACCESS_TOKEN" npx @vscode/vsce publish --packagePath "$VSIX_PATH" --skip-duplicate $PRE_RELEASE_FLAG + else + [ -n "${OVSX_PAT:-}" ] || { echo 'OVSX_PAT is required'; exit 1; } + npx ovsx publish "$VSIX_PATH" -p "$OVSX_PAT" --skip-duplicate $PRE_RELEASE_FLAG + fi diff --git a/.github/actions/repackage-vsix-stable/action.yml b/.github/actions/repackage-vsix-stable/action.yml new file mode 100644 index 00000000..0c2610d6 --- /dev/null +++ b/.github/actions/repackage-vsix-stable/action.yml @@ -0,0 +1,52 @@ +name: Repackage VSIX for Stable +description: Repackages a pre-release VSIX as a stable Lana build. + +inputs: + source-vsix-path: + description: Source pre-release VSIX. + required: true + prerelease-version: + description: Pre-release version, used for logging. + required: true + stable-version: + description: Stable version to stamp into the VSIX. + required: true + output-dir: + description: Output directory for the stable VSIX. + default: ./vsix-artifacts + +outputs: + vsix-path: + description: Path to the repackaged stable VSIX. + value: ${{ steps.repack.outputs.vsix-path }} + +runs: + using: composite + steps: + - name: Repackage VSIX with stable version + id: repack + shell: bash + env: + SOURCE_VSIX: ${{ inputs.source-vsix-path }} + STABLE_VERSION: ${{ inputs.stable-version }} + OUTPUT_DIR: ${{ inputs.output-dir }} + run: | + set -euo pipefail + [ -f "$SOURCE_VSIX" ] || { echo "Source VSIX not found: $SOURCE_VSIX"; exit 1; } + + WORK_DIR=./vsix-repack + rm -rf "$WORK_DIR" + mkdir -p "$WORK_DIR" "$OUTPUT_DIR" + unzip -q "$SOURCE_VSIX" -d "$WORK_DIR" + + PKG_JSON="$WORK_DIR/extension/package.json" + [ -f "$PKG_JSON" ] || { echo "extension/package.json not found in VSIX"; exit 1; } + node -e "const fs=require('fs'); const path='$PKG_JSON'; const pkg=JSON.parse(fs.readFileSync(path,'utf8')); pkg.version='$STABLE_VERSION'; delete pkg.preRelease; fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n');" + + MANIFEST="$WORK_DIR/extension.vsixmanifest" + [ -f "$MANIFEST" ] || { echo "extension.vsixmanifest not found in VSIX"; exit 1; } + STABLE_VERSION="$STABLE_VERSION" node -e 'const fs=require("fs"); const path=process.argv[1]; const xml=fs.readFileSync(path,"utf8"); const updated=xml.replace(/(]*\bVersion=")[^"]*(")/, `$1${process.env.STABLE_VERSION}$2`); if (updated === xml) throw new Error("VSIX manifest does not contain an Identity Version"); fs.writeFileSync(path, updated);' "$MANIFEST" + + OUT_ABS="$(cd "$OUTPUT_DIR" && pwd)/lana-${STABLE_VERSION}.vsix" + (cd "$WORK_DIR" && zip -q -r "$OUT_ABS" .) + echo "vsix-path=$OUT_ABS" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..ca77315b --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,47 @@ +name: Nightly Release + +on: + workflow_dispatch: + inputs: + dry-run: + description: 'Run in dry-run mode (no actual publishing)' + required: false + default: true + type: boolean + # schedule: + # - cron: '0 4 * * *' + +concurrency: + group: nightly-${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: false + +permissions: + contents: write + packages: write + actions: read + +jobs: + nightly-release: + uses: salesforcecli/github-workflows/.github/workflows/vscode-publish-extensions.yml@ph/W-23832274-pnpm-stable-promotion + with: + branch: main + extensions: lana + registries: 'all' + pre-release: 'true' + nightly: true + version-bump: 'auto' + extensions-root: . + exclude-web-vsix: 'true' + node-version: '24' + package-manager: pnpm + package-manager-version: '10' + cache-dependency-path: pnpm-lock.yaml + install-command: pnpm run ci:install + package-command: cd lana && pnpm dlx @vscode/vsce package --target web --no-dependencies --out lana-$(node -p "require('./package.json').version").vsix + prerelease-package-command: cd lana && pnpm dlx @vscode/vsce package --pre-release --target web --no-dependencies --out lana-$(node -p "require('./package.json').version").vsix + web-package-command: cd lana && pnpm dlx @vscode/vsce package --target web --no-dependencies --out lana-web-$(node -p "require('./package.json').version").vsix + web-prerelease-package-command: cd lana && pnpm dlx @vscode/vsce package --pre-release --target web --no-dependencies --out lana-web-$(node -p "require('./package.json').version").vsix + artifact-glob: lana/*.vsix + publish-web-vsix: false + dry-run: ${{ inputs.dry-run && 'true' || 'false' }} + secrets: inherit diff --git a/.github/workflows/promote-prerelease.yml b/.github/workflows/promote-prerelease.yml new file mode 100644 index 00000000..3a5af7cd --- /dev/null +++ b/.github/workflows/promote-prerelease.yml @@ -0,0 +1,34 @@ +name: Promote Nightly to Pre-release + +on: + # schedule: + # # Wednesdays at 07:00 UTC. Enable once nightly releases are established. + # - cron: '0 7 * * 3' + workflow_dispatch: + inputs: + min-tag-age-days: + description: 'Minimum nightly age in days before eligible for promotion' + required: false + default: '7' + type: string + dry-run: + description: 'Run in dry-run mode (no actual publishing or tagging)' + required: false + default: true + type: boolean + +permissions: + contents: write + packages: write + actions: read + +jobs: + promote: + uses: salesforcecli/github-workflows/.github/workflows/vscode-promote-prerelease.yml@ph/W-23832274-pnpm-stable-promotion + with: + min-tag-age-days: ${{ inputs.min-tag-age-days || '7' }} + vsix-name-pattern: 'lana-*.vsix' + exclude-web-vsix: 'true' + extension-name: lana + dry-run: ${{ inputs.dry-run && 'true' || 'false' }} + secrets: inherit diff --git a/.github/workflows/promote-stable.yml b/.github/workflows/promote-stable.yml new file mode 100644 index 00000000..6b9b6635 --- /dev/null +++ b/.github/workflows/promote-stable.yml @@ -0,0 +1,38 @@ +name: Promote Pre-release to Stable + +on: + # Cron schedule intentionally disabled — promotion to stable is manual only. + # To enable scheduled promotion, restore the schedule trigger below: + # schedule: + # # Wednesdays at 06:00 UTC, before pre-release promotion. + # - cron: '0 6 * * 3' + workflow_dispatch: + inputs: + dry-run: + description: 'Run in dry-run mode (no actual publishing or tagging)' + required: false + default: true + type: boolean + +permissions: + contents: write + packages: write + actions: read + +jobs: + promote: + uses: salesforcecli/github-workflows/.github/workflows/vscode-promote-stable.yml@ph/W-23832274-pnpm-stable-promotion + with: + extension-name: lana + vsix-name-pattern: 'lana-*.vsix' + exclude-web-vsix: 'true' + extensions-root: . + node-version: '24' + package-manager: pnpm + package-manager-version: '10' + cache-dependency-path: pnpm-lock.yaml + lockfile-path: pnpm-lock.yaml + install-command: pnpm run ci:install + required-checks: E2E + dry-run: ${{ inputs.dry-run && 'true' || 'false' }} + secrets: inherit diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b6a0612b..e53a6f62 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -35,7 +35,7 @@ permissions: jobs: publish: - uses: salesforcecli/github-workflows/.github/workflows/vscode-publish-extensions.yml@main + uses: salesforcecli/github-workflows/.github/workflows/vscode-publish-extensions.yml@ph/W-23832274-pnpm-stable-promotion with: branch: ${{ github.ref_name }} extensions: lana @@ -56,5 +56,47 @@ jobs: web-package-command: cd lana && pnpm dlx @vscode/vsce package --target web --no-dependencies --out lana-web-$(node -p "require('./package.json').version").vsix web-prerelease-package-command: cd lana && pnpm dlx @vscode/vsce package --target web --pre-release --no-dependencies --out lana-web-$(node -p "require('./package.json').version").vsix artifact-glob: lana/*.vsix - publish-web-vsix: true + publish-web-vsix: false secrets: inherit + + publish-to-cbweb-marketplace: + name: Publish to CBWeb Internal Marketplace + needs: publish + runs-on: ubuntu-latest + steps: + - name: Download release artifacts + uses: actions/download-artifact@v8 + with: + path: ./vsix-artifacts + + - name: Find web VSIX + id: web-vsix + run: | + mapfile -t VSIX_FILES < <(find ./vsix-artifacts -type f -name 'lana-web-*.vsix') + if [ "${#VSIX_FILES[@]}" -ne 1 ]; then + echo "Expected exactly one web VSIX artifact, found ${#VSIX_FILES[@]}" + exit 1 + fi + + echo "vsix_file=${VSIX_FILES[0]}" >> "$GITHUB_OUTPUT" + + - name: Publish web VSIX to CBWeb internal marketplace + if: inputs.dry-run == false + env: + MARKETPLACE_URL: ${{ vars.MARKETPLACE_URL }} + MARKETPLACE_DEPLOY_TOKEN: ${{ secrets.MARKETPLACE_DEPLOY_TOKEN }} + VSIX_FILE: ${{ steps.web-vsix.outputs.vsix_file }} + run: | + if [ -z "$MARKETPLACE_URL" ] || [ -z "$MARKETPLACE_DEPLOY_TOKEN" ]; then + echo "CBWeb marketplace credentials are required for live publishing" + exit 1 + fi + + curl --fail-with-body \ + -X POST "${MARKETPLACE_URL}/api/internal/publish" \ + -H "Authorization: Bearer ${MARKETPLACE_DEPLOY_TOKEN}" \ + -F "vsix=@${VSIX_FILE}" + + - name: Dry-run summary + if: inputs.dry-run + run: 'echo "DRY RUN: Would publish ${{ steps.web-vsix.outputs.vsix_file }} to CBWeb marketplace"' diff --git a/scripts/verify-web-distribution.mjs b/scripts/verify-web-distribution.mjs index 0714eb66..0226e7e8 100644 --- a/scripts/verify-web-distribution.mjs +++ b/scripts/verify-web-distribution.mjs @@ -1,10 +1,26 @@ import fs from 'node:fs'; const ci = fs.readFileSync('.github/workflows/ci.yml', 'utf8'); +const nightly = fs.readFileSync('.github/workflows/nightly.yml', 'utf8'); const publish = fs.readFileSync('.github/workflows/publish.yml', 'utf8'); +const promotePrerelease = fs.readFileSync('.github/workflows/promote-prerelease.yml', 'utf8'); +const promoteStable = fs.readFileSync('.github/workflows/promote-stable.yml', 'utf8'); const e2e = fs.readFileSync('.github/workflows/e2e.yml', 'utf8'); const manifest = JSON.parse(fs.readFileSync('lana/package.json', 'utf8')); +function yamlSection(source, start, end) { + const startIndex = source.indexOf(start); + if (startIndex === -1) { + return ''; + } + + const remainder = source.slice(startIndex + start.length); + const endIndex = remainder.search(end); + return endIndex === -1 ? remainder : remainder.slice(0, endIndex); +} + +const publishJob = yamlSection(publish, ' publish:\n', /^ \w[^\n]*:\n/m); + const requiredPublishCommands = [ ['package-command', 'vsce package --target web --no-dependencies'], ['prerelease-package-command', 'vsce package --pre-release --target web --no-dependencies'], @@ -13,16 +29,142 @@ const requiredPublishCommands = [ ]; for (const [name, command] of requiredPublishCommands) { - const match = publish.match(new RegExp(`^\\s{6}${name}: (.+)$`, 'm')); + const match = publishJob.match(new RegExp(`^ ${name}: (.+)$`, 'm')); if (!match?.[1].includes(command)) { throw new Error(`Expected \`${name}\` to include \`${command}\`.`); } } +for (const [name, requiredOutput] of [ + ['web-package-command', 'lana-web-$(node -p "require(\'./package.json\').version").vsix'], + ['web-prerelease-package-command', 'lana-web-$(node -p "require(\'./package.json\').version").vsix'], +]) { + const match = publishJob.match(new RegExp(`^ ${name}: (.+)$`, 'm')); + if (!match?.[1].includes(requiredOutput)) { + throw new Error(`Expected \`${name}\` to produce a \`lana-web-.vsix\` artifact.`); + } +} + +if (!publishJob.includes(' artifact-glob: lana/*.vsix')) { + throw new Error('Expected the publish workflow to upload Lana VSIX artifacts.'); +} + +if (!publishJob.includes(' publish-web-vsix: false')) { + throw new Error('Expected the shared workflow CBWeb publish path to be disabled.'); +} + +const cbwebJob = yamlSection( + publish, + ' publish-to-cbweb-marketplace:\n', + /^ \w[^\n]*:\n/m, +); +for (const requiredText of [ + 'needs: publish', + 'uses: actions/download-artifact@v8', + "find ./vsix-artifacts -type f -name 'lana-web-*.vsix'", + 'Expected exactly one web VSIX artifact', + 'MARKETPLACE_URL: ${{ vars.MARKETPLACE_URL }}', + 'MARKETPLACE_DEPLOY_TOKEN: ${{ secrets.MARKETPLACE_DEPLOY_TOKEN }}', + 'if: inputs.dry-run == false', + 'if: inputs.dry-run', + 'curl --fail-with-body', + '${MARKETPLACE_URL}/api/internal/publish', + '-F "vsix=@${VSIX_FILE}"', +]) { + if (!cbwebJob.includes(requiredText)) { + throw new Error(`Expected the CBWeb publish job to include \`${requiredText}\`.`); + } +} + +for (const requiredText of [ + 'workflow_dispatch:', + "# - cron: '0 7 * * 3'", + 'uses: salesforcecli/github-workflows/.github/workflows/vscode-promote-prerelease.yml@ph/W-23832274-pnpm-stable-promotion', + "min-tag-age-days: ${{ inputs.min-tag-age-days || '7' }}", + "vsix-name-pattern: 'lana-*.vsix'", + "exclude-web-vsix: 'true'", + 'extension-name: lana', + "dry-run: ${{ inputs.dry-run && 'true' || 'false' }}", + 'secrets: inherit', +]) { + if (!promotePrerelease.includes(requiredText)) { + throw new Error(`Expected pre-release promotion workflow to include \`${requiredText}\`.`); + } +} + +if (/^ schedule:/m.test(promotePrerelease)) { + throw new Error('Pre-release promotion schedule must remain disabled.'); +} + +for (const requiredText of [ + 'workflow_dispatch:', + "# - cron: '0 6 * * 3'", + 'uses: salesforcecli/github-workflows/.github/workflows/vscode-promote-stable.yml@ph/W-23832274-pnpm-stable-promotion', + 'extension-name: lana', + "vsix-name-pattern: 'lana-*.vsix'", + "exclude-web-vsix: 'true'", + 'extensions-root: .', + "node-version: '24'", + 'package-manager: pnpm', + "package-manager-version: '10'", + 'cache-dependency-path: pnpm-lock.yaml', + 'lockfile-path: pnpm-lock.yaml', + 'install-command: pnpm run ci:install', + 'required-checks: E2E', + "dry-run: ${{ inputs.dry-run && 'true' || 'false' }}", + 'secrets: inherit', +]) { + if (!promoteStable.includes(requiredText)) { + throw new Error(`Expected stable promotion workflow to include \`${requiredText}\`.`); + } +} + +if (/^ schedule:/m.test(promoteStable)) { + throw new Error('Stable promotion schedule must remain disabled.'); +} + +for (const prohibitedText of ['actions/checkout', 'setupNodeAndInstall', 'pnpm dlx @vscode/vsce']) { + if (cbwebJob.includes(prohibitedText)) { + throw new Error(`The CBWeb publish job must use the shared release artifact, not \`${prohibitedText}\`.`); + } +} + +const workflowDispatch = yamlSection(publish, ' workflow_dispatch:\n', /^ \w[^\n]*:/m); +if (!/ dry-run:\n description: .+\n required: true\n default: true\n type: boolean/.test(workflowDispatch)) { + throw new Error('Expected release dry runs to be enabled by default.'); +} + if (!ci.includes('vsce package --target web --no-dependencies')) { throw new Error('Expected CI package validation to use the web target.'); } +for (const requiredText of [ + "# - cron: '0 4 * * *'", + 'uses: salesforcecli/github-workflows/.github/workflows/vscode-publish-extensions.yml@ph/W-23832274-pnpm-stable-promotion', + 'extensions: lana', + "pre-release: 'true'", + 'branch: main', + 'nightly: true', + "version-bump: 'auto'", + 'extensions-root: .', + 'package-manager: pnpm', + "exclude-web-vsix: 'true'", + 'publish-web-vsix: false', + "dry-run: ${{ inputs.dry-run && 'true' || 'false' }}", +]) { + if (!nightly.includes(requiredText)) { + throw new Error(`Expected nightly release workflow to include \`${requiredText}\`.`); + } +} + +if (!nightly.includes('default: true')) { + throw new Error('Manual nightly releases must default to dry-run mode.'); +} + +if (/^ schedule:/m.test(nightly)) { + throw new Error('Nightly release schedule must remain disabled.'); +} + if (manifest.main !== 'dist/Main.js' || manifest.browser !== 'dist/web/Main.web.js') { throw new Error('Desktop and web extension entry points must remain available for development.'); }