From 8f7bb74be69894b93218be685f4f1010e12e1e45 Mon Sep 17 00:00:00 2001 From: gdlol <.> Date: Tue, 6 Oct 2026 14:58:24 +0000 Subject: [PATCH] ripgrep feature --- .config/cspell/cspell.json | 1 + .github/workflows/renovate.yml | 8 +- .github/workflows/ripgrep.yml | 134 +++++++++++++ features/ripgrep/Dockerfile | 9 + features/ripgrep/NOTES.md | 10 + features/ripgrep/README.md | 27 +++ features/ripgrep/config.json | 16 ++ features/ripgrep/feature.json | 6 + features/ripgrep/install | 20 ++ scripts/tasks/features/dotnet/buildImage.ts | 3 +- scripts/tasks/features/dotnet/planPublish.ts | 57 ++---- scripts/tasks/features/dotnet/publishImage.ts | 16 +- scripts/tasks/features/dotnet/renovate.ts | 82 +------- scripts/tasks/features/dotnet/tagPublished.ts | 32 +--- scripts/tasks/features/dotnet/tags.ts | 56 +----- scripts/tasks/features/image.ts | 15 ++ scripts/tasks/features/planPublish.ts | 53 ++++++ .../tasks/features/{dotnet => }/registry.ts | 0 scripts/tasks/features/renovate.ts | 85 +++++++++ .../tasks/features/ripgrep/buildContext.ts | 40 ++++ scripts/tasks/features/ripgrep/buildImage.ts | 59 ++++++ .../tasks/features/ripgrep/fetchPayload.ts | 26 +++ .../tasks/features/ripgrep/generateConfig.ts | 177 ++++++++++++++++++ scripts/tasks/features/ripgrep/invocation.ts | 26 +++ scripts/tasks/features/ripgrep/paths.ts | 52 +++++ scripts/tasks/features/ripgrep/payload.ts | 84 +++++++++ scripts/tasks/features/ripgrep/planPublish.ts | 39 ++++ .../tasks/features/ripgrep/publishImage.ts | 45 +++++ scripts/tasks/features/ripgrep/renovate.ts | 59 ++++++ .../tasks/features/ripgrep/tagPublished.ts | 29 +++ scripts/tasks/features/ripgrep/tags.ts | 24 +++ scripts/tasks/features/tagPublished.ts | 30 +++ scripts/tasks/features/tags.ts | 60 ++++++ scripts/verify/dotnet/assertions.ts | 2 +- scripts/verify/dotnet/lifecycle.ts | 2 +- scripts/verify/dotnet/serve.ts | 4 +- scripts/verify/dotnet/verify.ts | 2 +- scripts/verify/ripgrep/assertions.ts | 34 ++++ scripts/verify/ripgrep/lifecycle.ts | 165 ++++++++++++++++ scripts/verify/ripgrep/selection.ts | 24 +++ scripts/verify/ripgrep/staging.ts | 16 ++ scripts/verify/ripgrep/test-image.Dockerfile | 7 + scripts/verify/ripgrep/verify.ts | 114 +++++++++++ 43 files changed, 1538 insertions(+), 212 deletions(-) create mode 100644 .github/workflows/ripgrep.yml create mode 100644 features/ripgrep/Dockerfile create mode 100644 features/ripgrep/NOTES.md create mode 100644 features/ripgrep/README.md create mode 100644 features/ripgrep/config.json create mode 100644 features/ripgrep/feature.json create mode 100755 features/ripgrep/install create mode 100644 scripts/tasks/features/image.ts create mode 100644 scripts/tasks/features/planPublish.ts rename scripts/tasks/features/{dotnet => }/registry.ts (100%) create mode 100644 scripts/tasks/features/renovate.ts create mode 100644 scripts/tasks/features/ripgrep/buildContext.ts create mode 100644 scripts/tasks/features/ripgrep/buildImage.ts create mode 100644 scripts/tasks/features/ripgrep/fetchPayload.ts create mode 100644 scripts/tasks/features/ripgrep/generateConfig.ts create mode 100644 scripts/tasks/features/ripgrep/invocation.ts create mode 100644 scripts/tasks/features/ripgrep/paths.ts create mode 100644 scripts/tasks/features/ripgrep/payload.ts create mode 100644 scripts/tasks/features/ripgrep/planPublish.ts create mode 100644 scripts/tasks/features/ripgrep/publishImage.ts create mode 100644 scripts/tasks/features/ripgrep/renovate.ts create mode 100644 scripts/tasks/features/ripgrep/tagPublished.ts create mode 100644 scripts/tasks/features/ripgrep/tags.ts create mode 100644 scripts/tasks/features/tagPublished.ts create mode 100644 scripts/tasks/features/tags.ts create mode 100644 scripts/verify/ripgrep/assertions.ts create mode 100644 scripts/verify/ripgrep/lifecycle.ts create mode 100644 scripts/verify/ripgrep/selection.ts create mode 100644 scripts/verify/ripgrep/staging.ts create mode 100644 scripts/verify/ripgrep/test-image.Dockerfile create mode 100644 scripts/verify/ripgrep/verify.ts diff --git a/.config/cspell/cspell.json b/.config/cspell/cspell.json index 1ef570c..daa986f 100644 --- a/.config/cspell/cspell.json +++ b/.config/cspell/cspell.json @@ -22,6 +22,7 @@ "msbuild", "NOLOGO", "packagejson", + "pcre", "syncpack", "unrs", "muxer", diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index 6a79687..ef674a9 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -12,6 +12,10 @@ permissions: jobs: renovate: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + feature: [dotnet, ripgrep] steps: - name: Checkout (GitHub) uses: actions/checkout@v7 @@ -25,11 +29,11 @@ jobs: username: ${{ github.repository_owner }} password: ${{ github.token }} - - name: Refresh the dotnet config + - name: Refresh the ${{ matrix.feature }} config uses: devcontainers/ci@v0.3 with: cacheFrom: ghcr.io/${{ github.repository }}/devcontainer push: never - runCmd: pnpm tsx scripts/tasks/features/dotnet/renovate.ts + runCmd: pnpm tsx scripts/tasks/features/${{ matrix.feature }}/renovate.ts env: | GH_TOKEN=${{ secrets.RENOVATE_TOKEN }} diff --git a/.github/workflows/ripgrep.yml b/.github/workflows/ripgrep.yml new file mode 100644 index 0000000..bc695ba --- /dev/null +++ b/.github/workflows/ripgrep.yml @@ -0,0 +1,134 @@ +name: "Ripgrep" + +on: + pull_request: + paths: + - "features/ripgrep/config.json" + push: + branches: + - main + paths: + - "features/ripgrep/config.json" + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: "ripgrep-${{ github.ref }}" + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + plan: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + steps: + - name: Checkout (GitHub) + uses: actions/checkout@v7 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ github.token }} + + - name: Plan the publishes + id: plan + uses: devcontainers/ci@v0.3 + with: + cacheFrom: ghcr.io/${{ github.repository }}/devcontainer + push: never + runCmd: pnpm tsx scripts/tasks/features/ripgrep/planPublish.ts --channel "$CHANNEL" + env: | + CHANNEL=${{ github.head_ref || github.ref_name }} + GH_TOKEN=${{ github.token }} + + image: + needs: plan + if: needs.plan.outputs.matrix != '' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} + steps: + - name: Checkout (GitHub) + uses: actions/checkout@v7 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ github.token }} + + - name: Publish the image + uses: devcontainers/ci@v0.3 + with: + cacheFrom: ghcr.io/${{ github.repository }}/devcontainer + push: never + runCmd: pnpm tsx scripts/tasks/features/ripgrep/publishImage.ts --version ${{ matrix.version }} --channel "$CHANNEL" + env: | + CHANNEL=${{ github.head_ref || github.ref_name }} + GH_TOKEN=${{ github.token }} + + commit-tags: + needs: + - plan + - image + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.plan.result == 'success' && needs.image.result == 'success' && needs.plan.outputs.matrix != '' + permissions: + contents: write + packages: read + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} + steps: + - name: Checkout (GitHub) + uses: actions/checkout@v7 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ github.token }} + + - name: Tag the published version + uses: devcontainers/ci@v0.3 + with: + cacheFrom: ghcr.io/${{ github.repository }}/devcontainer + push: never + runCmd: pnpm tsx scripts/tasks/features/ripgrep/tagPublished.ts --version ${{ matrix.version }} + env: | + GH_TOKEN=${{ github.token }} + + verify: + needs: + - plan + - image + if: ${{ !cancelled() && needs.plan.result == 'success' && (needs.image.result == 'success' || needs.image.result == 'skipped') }} + runs-on: ubuntu-latest + steps: + - name: Checkout (GitHub) + uses: actions/checkout@v7 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ github.token }} + + - name: Verify the image + uses: devcontainers/ci@v0.3 + with: + cacheFrom: ghcr.io/${{ github.repository }}/devcontainer + push: never + runCmd: pnpm verify ripgrep --images pull --channel "$CHANNEL" + env: | + CHANNEL=${{ github.head_ref || github.ref_name }} + GH_TOKEN=${{ github.token }} diff --git a/features/ripgrep/Dockerfile b/features/ripgrep/Dockerfile new file mode 100644 index 0000000..c0e4cf1 --- /dev/null +++ b/features/ripgrep/Dockerfile @@ -0,0 +1,9 @@ +FROM scratch AS linux-amd64-base +FROM scratch AS linux-arm64-base + +ARG TARGETOS +ARG TARGETARCH +FROM ${TARGETOS}-${TARGETARCH}-base +ARG TARGETOS +ARG TARGETARCH +COPY ${TARGETOS}/${TARGETARCH}/features/ripgrep/ /features/ripgrep/ diff --git a/features/ripgrep/NOTES.md b/features/ripgrep/NOTES.md new file mode 100644 index 0000000..8bbf27e --- /dev/null +++ b/features/ripgrep/NOTES.md @@ -0,0 +1,10 @@ +## Installed files + +| Source | Destination | Mode | +| ------------------------------------- | ---------------------------------------------------- | ---- | +| `rg` | `/usr/local/bin/rg` | 0755 | +| `doc/rg.1` | `/usr/local/share/man/man1/rg.1` | 0644 | +| `complete/rg.bash` | `/usr/local/share/bash-completion/completions/rg` | 0644 | +| `complete/_rg` | `/usr/local/share/zsh/site-functions/_rg` | 0644 | +| `complete/rg.fish` | `/usr/local/share/fish/vendor_completions.d/rg.fish` | 0644 | +| `COPYING`, `LICENSE-MIT`, `UNLICENSE` | `/usr/local/share/doc/ripgrep/` | 0644 | diff --git a/features/ripgrep/README.md b/features/ripgrep/README.md new file mode 100644 index 0000000..7fb8b9c --- /dev/null +++ b/features/ripgrep/README.md @@ -0,0 +1,27 @@ +# Ripgrep (ripgrep) + +Installs the ripgrep line-oriented search tool. + +## Example Usage + +```dockerfile +FROM ghcr.io/devcontainer-config/features/ripgrep AS ripgrep + +FROM base-image +COPY --from=ripgrep /features/ripgrep/ /opt/devcontainer-config/features/ripgrep/ +``` + +## Installed files + +| Source | Destination | Mode | +| ------------------------------------- | ---------------------------------------------------- | ---- | +| `rg` | `/usr/local/bin/rg` | 0755 | +| `doc/rg.1` | `/usr/local/share/man/man1/rg.1` | 0644 | +| `complete/rg.bash` | `/usr/local/share/bash-completion/completions/rg` | 0644 | +| `complete/_rg` | `/usr/local/share/zsh/site-functions/_rg` | 0644 | +| `complete/rg.fish` | `/usr/local/share/fish/vendor_completions.d/rg.fish` | 0644 | +| `COPYING`, `LICENSE-MIT`, `UNLICENSE` | `/usr/local/share/doc/ripgrep/` | 0644 | + +--- + +_Note: This file was auto-generated from the [feature.json](https://github.com/devcontainer-config/features/blob/main/features/ripgrep/feature.json). Add additional notes to a `NOTES.md`._ diff --git a/features/ripgrep/config.json b/features/ripgrep/config.json new file mode 100644 index 0000000..908d017 --- /dev/null +++ b/features/ripgrep/config.json @@ -0,0 +1,16 @@ +{ + "revision": 1, + "versions": { + "15.2.0": { + "aliases": ["15.2", "15", "latest"], + "x86_64-unknown-linux-musl": { + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-x86_64-unknown-linux-musl.tar.gz", + "sha256": "33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c" + }, + "aarch64-unknown-linux-musl": { + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-aarch64-unknown-linux-musl.tar.gz", + "sha256": "800b1e7206afe799dfb5a6901f23147cfaabe0e52210538100f61e86e1740915" + } + } + } +} diff --git a/features/ripgrep/feature.json b/features/ripgrep/feature.json new file mode 100644 index 0000000..79a3e84 --- /dev/null +++ b/features/ripgrep/feature.json @@ -0,0 +1,6 @@ +{ + "id": "ripgrep", + "name": "Ripgrep", + "description": "Installs the ripgrep line-oriented search tool.", + "image": "ghcr.io/devcontainer-config/features/ripgrep" +} diff --git a/features/ripgrep/install b/features/ripgrep/install new file mode 100755 index 0000000..71f75a7 --- /dev/null +++ b/features/ripgrep/install @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +mkdir -p /usr/local/bin +install -m 0755 rg /usr/local/bin/rg + +mkdir -p /usr/local/share/man/man1 +install -m 0644 doc/rg.1 /usr/local/share/man/man1/rg.1 + +mkdir -p /usr/local/share/bash-completion/completions +install -m 0644 complete/rg.bash /usr/local/share/bash-completion/completions/rg + +mkdir -p /usr/local/share/zsh/site-functions +install -m 0644 complete/_rg /usr/local/share/zsh/site-functions/_rg + +mkdir -p /usr/local/share/fish/vendor_completions.d +install -m 0644 complete/rg.fish /usr/local/share/fish/vendor_completions.d/rg.fish + +mkdir -p /usr/local/share/doc/ripgrep +install -m 0644 COPYING LICENSE-MIT UNLICENSE /usr/local/share/doc/ripgrep/ diff --git a/scripts/tasks/features/dotnet/buildImage.ts b/scripts/tasks/features/dotnet/buildImage.ts index bfcec55..ae493db 100644 --- a/scripts/tasks/features/dotnet/buildImage.ts +++ b/scripts/tasks/features/dotnet/buildImage.ts @@ -2,13 +2,14 @@ import path from "node:path"; import { parseArgs } from "node:util"; import { $$ } from "@/scripts/shell.js"; +import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js"; import { assembleBuildContext } from "./buildContext.js"; import { readConfig } from "./generateConfig.js"; import { parseInvocation } from "./invocation.js"; import { ensurePackBinary } from "./packBinary.js"; import { paths } from "./paths.js"; -import { defaultRefPrefix, imageRefs, mainChannel, tagNames } from "./tags.js"; +import { imageRefs, tagNames } from "./tags.js"; const main = async (): Promise => { const { values } = parseArgs({ diff --git a/scripts/tasks/features/dotnet/planPublish.ts b/scripts/tasks/features/dotnet/planPublish.ts index 5874e04..19119f9 100644 --- a/scripts/tasks/features/dotnet/planPublish.ts +++ b/scripts/tasks/features/dotnet/planPublish.ts @@ -1,20 +1,10 @@ -import { appendFile, mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; import { parseArgs } from "node:util"; -import { $ } from "execa"; +import { planPublish } from "@/scripts/tasks/features/planPublish.js"; +import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js"; -import type { Component } from "./generateConfig.js"; import { components, readConfig } from "./generateConfig.js"; -import { login } from "./registry.js"; -import { canonicalTag, defaultRefPrefix, imageRef, mainChannel, tagNames } from "./tags.js"; - -interface PlanEntry { - component: Component; - version: string; -} - -const $$inspect = $({ reject: false, stdio: "ignore" }); +import { canonicalTag, imageRef, tagNames } from "./tags.js"; const main = async (): Promise => { const { values } = parseArgs({ @@ -31,38 +21,15 @@ const main = async (): Promise => { const pairs = components.flatMap((component) => Object.keys(config.components[component].versions).map((version) => ({ component, version })), ); - const derived = pairs.map(({ component, version }) => ({ - component, - version, - canonical: imageRef(prefix, component, canonicalTag(config, component, version, channel)), - tags: tagNames(config, component, version, channel), - })); - - if (channel === mainChannel) { - await login(prefix); - } - const listed: PlanEntry[] = []; - for (const pair of derived) { - if (channel === mainChannel) { - const inspect = await $$inspect`docker buildx imagetools inspect ${pair.canonical}`; - if (inspect.exitCode === 0) { - console.log(`${pair.component} ${pair.version}: already published (${pair.canonical})`); - continue; - } - } - console.log(`${pair.component} ${pair.version}: to publish (${pair.tags.join(", ")})`); - listed.push({ component: pair.component, version: pair.version }); - } - - const plan = { include: listed }; - const outPath = path.resolve(values.out); - await mkdir(path.dirname(outPath), { recursive: true }); - await writeFile(outPath, `${JSON.stringify(plan, null, 2)}\n`); - const githubOutput = process.env.GITHUB_OUTPUT; - if (githubOutput !== undefined && githubOutput !== "") { - await appendFile(githubOutput, `matrix=${listed.length === 0 ? "" : JSON.stringify(plan)}\n`); - } - console.log(`${listed.length} of ${pairs.length} pairs to publish (${outPath})`); + await planPublish( + pairs.map(({ component, version }) => ({ + label: `${component} ${version}`, + canonical: imageRef(prefix, component, canonicalTag(config, component, version, channel)), + tags: tagNames(config, component, version, channel), + entry: { component, version }, + })), + { channel, prefix, out: values.out }, + ); }; if (import.meta.main) { diff --git a/scripts/tasks/features/dotnet/publishImage.ts b/scripts/tasks/features/dotnet/publishImage.ts index 4306cf7..c6d111b 100644 --- a/scripts/tasks/features/dotnet/publishImage.ts +++ b/scripts/tasks/features/dotnet/publishImage.ts @@ -1,7 +1,8 @@ -import path from "node:path"; import { parseArgs } from "node:util"; -import { $$ } from "@/scripts/shell.js"; +import { pushImage } from "@/scripts/tasks/features/image.js"; +import { login } from "@/scripts/tasks/features/registry.js"; +import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js"; import { assembleBuildContext } from "./buildContext.js"; import { readConfig } from "./generateConfig.js"; @@ -9,8 +10,7 @@ import { resolveInstallBinaries } from "./installBinary.js"; import { parseComponent, parseVersion } from "./invocation.js"; import { paths } from "./paths.js"; import { ensurePayload } from "./payload.js"; -import { login } from "./registry.js"; -import { defaultRefPrefix, imageRefs, mainChannel, tagNames } from "./tags.js"; +import { imageRefs, tagNames } from "./tags.js"; const main = async (): Promise => { const { values } = parseArgs({ @@ -36,13 +36,7 @@ const main = async (): Promise => { const context = await assembleBuildContext(component, version, installBinaries); await login(prefix); const refs = imageRefs(prefix, component, tags); - await $$`docker buildx build ${[ - ...["--platform", paths.dockerArchOptions.map((arch) => `linux/${arch}`).join(",")], - "--push", - ...["--file", path.join(context, "docker", "Dockerfile")], - ...refs.flatMap((ref) => ["--tag", ref]), - context, - ]}`; + await pushImage(context, refs); console.log(refs.join("\n")); }; diff --git a/scripts/tasks/features/dotnet/renovate.ts b/scripts/tasks/features/dotnet/renovate.ts index 386ef6a..d3a88f5 100644 --- a/scripts/tasks/features/dotnet/renovate.ts +++ b/scripts/tasks/features/dotnet/renovate.ts @@ -2,18 +2,14 @@ import fs from "node:fs/promises"; import path from "node:path"; import { parseArgs } from "node:util"; -import { Octokit } from "@octokit/rest"; import git from "isomorphic-git"; -import http from "isomorphic-git/http/node"; -import { getRemoteInfo } from "@/scripts/git.js"; import { projectRoot } from "@/scripts/project.js"; +import { refresh } from "@/scripts/tasks/features/renovate.js"; import type { Component, FeatureConfig } from "./generateConfig.js"; import { components, configPath, generateConfig, parseConfig, readConfig } from "./generateConfig.js"; -const refreshBranch = "renovate"; -const refreshRef = `refs/heads/${refreshBranch}`; const title = "Update dotnet version pins"; const configFilepath = path.relative(projectRoot, configPath); const textDecoder = new TextDecoder(); @@ -43,73 +39,15 @@ const main = async (): Promise => { }); await generateConfig(); - const [row] = await git.statusMatrix({ fs, dir: projectRoot, filepaths: [configFilepath] }); - if (row === undefined) { - throw new Error(`git status returned no row for ${configFilepath}`); - } - if (row[1] === row[2]) { - console.log(`${configFilepath} is unchanged; nothing to do`); - return; - } - - const headOid = await git.resolveRef({ fs, dir: projectRoot, ref: "HEAD" }); - const body = versionPins(await headConfig(headOid), await readConfig()).join("\n"); - if (values["dry-run"]) { - console.log(`Dry run: the regenerated ${configFilepath} differs from HEAD; the refresh commit would be:`); - console.log(`${title}\n\n${body}`); - console.log("Dry run: no branch update, no push, no pull request"); - return; - } - - const token = process.env.GH_TOKEN; - if (token === undefined || token === "") { - throw new Error("GH_TOKEN environment variable is not set"); - } - const branch = await git.currentBranch({ fs, dir: projectRoot, fullname: false }); - if (!branch) { - throw new Error("Failed to determine the current branch"); - } - - await git.branch({ fs, dir: projectRoot, ref: refreshBranch, object: headOid, force: true }); - await git.add({ fs, dir: projectRoot, filepath: configFilepath }); - await git.commit({ - fs, - dir: projectRoot, - ref: refreshRef, - message: `${title}\n\n${body}`, - author: { name: "Renovate", email: "" }, - }); - const remote = (await git.listRemotes({ fs, dir: projectRoot })).at(0); - if (remote === undefined) { - throw new Error("Git remote not found"); - } - await git.push({ - fs, - http, - dir: projectRoot, - remote: remote.remote, - ref: refreshRef, - force: true, - onAuth: () => ({ username: "git", password: token }), - }); - - const { owner, repo } = await getRemoteInfo(); - const octokit = new Octokit({ auth: token }); - const open = await octokit.rest.pulls.list({ owner, repo, state: "open", head: `${owner}:${refreshBranch}` }); - if (open.data.length > 0) { - console.log(`Pushed ${refreshBranch}; the open pull request keeps its diff`); - return; - } - const created = await octokit.rest.pulls.create({ - owner, - repo, - head: refreshBranch, - base: branch, - draft: true, - title, - body, - }); - console.log(`Opened ${created.data.html_url}`); + await refresh( + { + branch: "renovate", + title, + filepath: configFilepath, + body: async (headOid) => versionPins(await headConfig(headOid), await readConfig()).join("\n"), + }, + values["dry-run"], + ); }; if (import.meta.main) { diff --git a/scripts/tasks/features/dotnet/tagPublished.ts b/scripts/tasks/features/dotnet/tagPublished.ts index 9ad9d3d..88fd50b 100644 --- a/scripts/tasks/features/dotnet/tagPublished.ts +++ b/scripts/tasks/features/dotnet/tagPublished.ts @@ -1,18 +1,11 @@ -import fs from "node:fs/promises"; import { parseArgs } from "node:util"; -import { Octokit } from "@octokit/rest"; -import git from "isomorphic-git"; - -import { getRemoteInfo } from "@/scripts/git.js"; -import { projectRoot } from "@/scripts/project.js"; +import { tagPublished } from "@/scripts/tasks/features/tagPublished.js"; +import { mainChannel } from "@/scripts/tasks/features/tags.js"; import { readConfig } from "./generateConfig.js"; import { parseComponent, parseVersion } from "./invocation.js"; -import { canonicalTag, mainChannel } from "./tags.js"; - -const isNotFound = (error: unknown): boolean => - typeof error === "object" && error !== null && "status" in error && error.status === 404; +import { canonicalTag } from "./tags.js"; const main = async (): Promise => { const { values } = parseArgs({ @@ -24,25 +17,8 @@ const main = async (): Promise => { const component = parseComponent(values.component); const version = parseVersion(values.version); - const token = process.env.GH_TOKEN; - if (token === undefined || token === "") { - throw new Error("GH_TOKEN environment variable is not set"); - } const config = await readConfig(); - const name = `features-dotnet-${component}-${canonicalTag(config, component, version, mainChannel)}`; - const oid = await git.resolveRef({ fs, dir: projectRoot, ref: "HEAD" }); - const { owner, repo } = await getRemoteInfo(); - const octokit = new Octokit({ auth: token }); - try { - await octokit.rest.git.getRef({ owner, repo, ref: `tags/${name}` }); - console.log(`Tag ${name} already exists`); - } catch (error) { - if (!isNotFound(error)) { - throw error; - } - await octokit.rest.git.createRef({ owner, repo, ref: `refs/tags/${name}`, sha: oid }); - console.log(`Tagged ${name} at ${oid}`); - } + await tagPublished(`features-dotnet-${component}-${canonicalTag(config, component, version, mainChannel)}`); }; if (import.meta.main) { diff --git a/scripts/tasks/features/dotnet/tags.ts b/scripts/tasks/features/dotnet/tags.ts index 78d8a74..577e09b 100644 --- a/scripts/tasks/features/dotnet/tags.ts +++ b/scripts/tasks/features/dotnet/tags.ts @@ -1,62 +1,22 @@ -import { getRemoteInfo } from "@/scripts/git.js"; +import type { TagSource } from "@/scripts/tasks/features/tags.js"; +import { canonicalTag as baseCanonicalTag, tagNames as baseTagNames } from "@/scripts/tasks/features/tags.js"; import type { Component, FeatureConfig } from "./generateConfig.js"; -export const mainChannel = "main"; - -const tagPattern = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/; - -const channelSuffix = (channel: string): string => { - const suffix = channel.replaceAll(/[^a-zA-Z0-9._-]/gu, "-").replaceAll(/^[-.]+|[-.]+$/gu, ""); - if (suffix === "") { - throw new Error(`Channel ${channel} has an empty tag suffix`); - } - return suffix; -}; - -const mainTagNames = (config: FeatureConfig, component: Component, version: string): readonly string[] => { +const sourceOf = (config: FeatureConfig, component: Component, version: string): TagSource => { const entry = config.components[component].versions[version]; if (entry === undefined) { const available = Object.keys(config.components[component].versions).join(", "); throw new Error(`Component ${component} has no version ${version} (available: ${available})`); } - return [`${version}-r${config.revision}`, version, ...entry.aliases]; -}; - -const channelTagNames = (names: readonly string[], channel: string): readonly string[] => { - if (channel === mainChannel) { - return names; - } - const suffix = channelSuffix(channel); - const mainTags = new Set(names); - return names.map((name) => { - const tag = `${name}-${suffix}`; - if (mainTags.has(tag)) { - throw new Error(`Channel ${channel}: derived tag ${tag} is one of the pair's main channel tags`); - } - return tag; - }); -}; - -export const tagNames = (config: FeatureConfig, component: Component, version: string, channel: string): string[] => { - const names = channelTagNames(mainTagNames(config, component, version), channel); - return names.map((tag) => { - if (!tagPattern.test(tag)) { - throw new Error(`Channel ${channel}: derived tag ${tag} is not a valid registry tag`); - } - return tag; - }); + return { version, revision: config.revision, aliases: entry.aliases }; }; -export const canonicalTag = (config: FeatureConfig, component: Component, version: string, channel: string): string => { - const [canonical] = tagNames(config, component, version, channel); - return canonical; -}; +export const tagNames = (config: FeatureConfig, component: Component, version: string, channel: string): string[] => + baseTagNames(sourceOf(config, component, version), channel); -export const defaultRefPrefix = async (): Promise => { - const { owner, repo } = await getRemoteInfo(); - return `ghcr.io/${owner}/${repo}`; -}; +export const canonicalTag = (config: FeatureConfig, component: Component, version: string, channel: string): string => + baseCanonicalTag(sourceOf(config, component, version), channel); export const imageRef = (prefix: string, component: Component, tag: string): string => `${prefix}/dotnet/${component}:${tag}`; diff --git a/scripts/tasks/features/image.ts b/scripts/tasks/features/image.ts new file mode 100644 index 0000000..ec382ff --- /dev/null +++ b/scripts/tasks/features/image.ts @@ -0,0 +1,15 @@ +import path from "node:path"; + +import { $$ } from "@/scripts/shell.js"; + +const platforms = ["linux/amd64", "linux/arm64"] as const; + +export const pushImage = async (context: string, refs: readonly string[]): Promise => { + await $$`docker buildx build ${[ + ...["--platform", platforms.join(",")], + "--push", + ...["--file", path.join(context, "docker", "Dockerfile")], + ...refs.flatMap((ref) => ["--tag", ref]), + context, + ]}`; +}; diff --git a/scripts/tasks/features/planPublish.ts b/scripts/tasks/features/planPublish.ts new file mode 100644 index 0000000..38db2a5 --- /dev/null +++ b/scripts/tasks/features/planPublish.ts @@ -0,0 +1,53 @@ +import { appendFile, mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { $ } from "execa"; + +import { login } from "./registry.js"; +import { mainChannel } from "./tags.js"; + +const $$inspect = $({ reject: false, stdio: "ignore" }); + +export interface PlanCandidate { + label: string; + canonical: string; + tags: readonly string[]; + entry: Entry; +} + +export interface PlanOptions { + channel: string; + prefix: string; + out: string; +} + +export const planPublish = async ( + candidates: readonly PlanCandidate[], + options: PlanOptions, +): Promise => { + if (options.channel === mainChannel) { + await login(options.prefix); + } + const listed: Entry[] = []; + for (const candidate of candidates) { + if (options.channel === mainChannel) { + const inspect = await $$inspect`docker buildx imagetools inspect ${candidate.canonical}`; + if (inspect.exitCode === 0) { + console.log(`${candidate.label}: already published (${candidate.canonical})`); + continue; + } + } + console.log(`${candidate.label}: to publish (${candidate.tags.join(", ")})`); + listed.push(candidate.entry); + } + + const plan = { include: listed }; + const outPath = path.resolve(options.out); + await mkdir(path.dirname(outPath), { recursive: true }); + await writeFile(outPath, `${JSON.stringify(plan, null, 2)}\n`); + const githubOutput = process.env.GITHUB_OUTPUT; + if (githubOutput !== undefined && githubOutput !== "") { + await appendFile(githubOutput, `matrix=${listed.length === 0 ? "" : JSON.stringify(plan)}\n`); + } + console.log(`${listed.length} of ${candidates.length} entries to publish (${outPath})`); +}; diff --git a/scripts/tasks/features/dotnet/registry.ts b/scripts/tasks/features/registry.ts similarity index 100% rename from scripts/tasks/features/dotnet/registry.ts rename to scripts/tasks/features/registry.ts diff --git a/scripts/tasks/features/renovate.ts b/scripts/tasks/features/renovate.ts new file mode 100644 index 0000000..a82224c --- /dev/null +++ b/scripts/tasks/features/renovate.ts @@ -0,0 +1,85 @@ +import fs from "node:fs/promises"; + +import { Octokit } from "@octokit/rest"; +import git from "isomorphic-git"; +import http from "isomorphic-git/http/node"; + +import { getRemoteInfo } from "@/scripts/git.js"; +import { projectRoot } from "@/scripts/project.js"; + +export interface RefreshOptions { + branch: string; + title: string; + filepath: string; + body: (headOid: string) => Promise; +} + +export const refresh = async (options: RefreshOptions, dryRun: boolean): Promise => { + const [row] = await git.statusMatrix({ fs, dir: projectRoot, filepaths: [options.filepath] }); + if (row === undefined) { + throw new Error(`git status returned no row for ${options.filepath}`); + } + if (row[1] === row[2]) { + console.log(`${options.filepath} is unchanged; nothing to do`); + return; + } + + const headOid = await git.resolveRef({ fs, dir: projectRoot, ref: "HEAD" }); + const body = await options.body(headOid); + if (dryRun) { + console.log(`Dry run: the regenerated ${options.filepath} differs from HEAD; the refresh commit would be:`); + console.log(`${options.title}\n\n${body}`); + console.log("Dry run: no branch update, no push, no pull request"); + return; + } + + const token = process.env.GH_TOKEN; + if (token === undefined || token === "") { + throw new Error("GH_TOKEN environment variable is not set"); + } + const branch = await git.currentBranch({ fs, dir: projectRoot, fullname: false }); + if (!branch) { + throw new Error("Failed to determine the current branch"); + } + + await git.branch({ fs, dir: projectRoot, ref: options.branch, object: headOid, force: true }); + await git.add({ fs, dir: projectRoot, filepath: options.filepath }); + await git.commit({ + fs, + dir: projectRoot, + ref: `refs/heads/${options.branch}`, + message: `${options.title}\n\n${body}`, + author: { name: "Renovate", email: "" }, + }); + const remote = (await git.listRemotes({ fs, dir: projectRoot })).at(0); + if (remote === undefined) { + throw new Error("Git remote not found"); + } + await git.push({ + fs, + http, + dir: projectRoot, + remote: remote.remote, + ref: `refs/heads/${options.branch}`, + force: true, + onAuth: () => ({ username: "git", password: token }), + }); + + const { owner, repo } = await getRemoteInfo(); + const octokit = new Octokit({ auth: token }); + const open = await octokit.rest.pulls.list({ owner, repo, state: "open", head: `${owner}:${options.branch}` }); + if (open.data.length > 0) { + console.log(`Pushed ${options.branch}; the open pull request keeps its diff`); + return; + } + const created = await octokit.rest.pulls.create({ + owner, + repo, + head: options.branch, + base: branch, + draft: true, + title: options.title, + body, + }); + console.log(`Opened ${created.data.html_url}`); +}; diff --git a/scripts/tasks/features/ripgrep/buildContext.ts b/scripts/tasks/features/ripgrep/buildContext.ts new file mode 100644 index 0000000..631165d --- /dev/null +++ b/scripts/tasks/features/ripgrep/buildContext.ts @@ -0,0 +1,40 @@ +import { chmod, copyFile, link, mkdir, readdir, readlink, rm, symlink } from "node:fs/promises"; +import path from "node:path"; + +import { projectRoot } from "@/scripts/project.js"; + +import type { DockerArch } from "./paths.js"; +import { paths } from "./paths.js"; + +const linkTree = async (source: string, target: string): Promise => { + for (const entry of await readdir(source, { withFileTypes: true })) { + const sourceEntry = path.join(source, entry.name); + const targetEntry = path.join(target, entry.name); + if (entry.isDirectory()) { + await mkdir(targetEntry); + await linkTree(sourceEntry, targetEntry); + } else if (entry.isFile()) { + await link(sourceEntry, targetEntry); + } else if (entry.isSymbolicLink()) { + await symlink(await readlink(sourceEntry), targetEntry); + } else { + throw new Error(`${sourceEntry}: unsupported payload entry`); + } + } +}; + +export const assembleBuildContext = async (version: string, arches: readonly DockerArch[]): Promise => { + const context = paths.contextPath(version); + await rm(context, { recursive: true, force: true }); + await mkdir(path.join(context, "docker"), { recursive: true }); + await copyFile(path.join(projectRoot, "features/ripgrep/Dockerfile"), path.join(context, "docker", "Dockerfile")); + for (const arch of arches) { + const staged = path.join(context, "linux", arch, "features", "ripgrep"); + await mkdir(staged, { recursive: true }); + await linkTree(paths.payloadPath(version, arch), staged); + const install = path.join(staged, "install"); + await copyFile(path.join(projectRoot, "features/ripgrep/install"), install); + await chmod(install, 0o755); + } + return context; +}; diff --git a/scripts/tasks/features/ripgrep/buildImage.ts b/scripts/tasks/features/ripgrep/buildImage.ts new file mode 100644 index 0000000..76bb85a --- /dev/null +++ b/scripts/tasks/features/ripgrep/buildImage.ts @@ -0,0 +1,59 @@ +import path from "node:path"; +import { parseArgs } from "node:util"; + +import { $$ } from "@/scripts/shell.js"; +import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js"; + +import { assembleBuildContext } from "./buildContext.js"; +import { readConfig } from "./generateConfig.js"; +import { parseInvocation } from "./invocation.js"; +import { paths } from "./paths.js"; +import { imageRefs, tagNames } from "./tags.js"; + +const main = async (): Promise => { + const { values } = parseArgs({ + options: { + version: { type: "string" }, + arch: { type: "string" }, + channel: { type: "string", default: mainChannel }, + prefix: { type: "string" }, + }, + }); + + const { version, arch } = parseInvocation(values); + const channel = values.channel; + const prefix = values.prefix ?? (await defaultRefPrefix()); + if (arch !== paths.hostArch()) { + throw new Error( + `--arch ${arch} does not match the host architecture ${paths.hostArch()}; local image builds build the current platform only`, + ); + } + + const payload = paths.payloadPath(version, arch); + if (!(await paths.isFile(path.join(payload, "rg")))) { + throw new Error( + `No payload at ${payload}; run scripts/tasks/features/ripgrep/fetchPayload.ts --version ${version} --arch ${arch} first`, + ); + } + + const context = await assembleBuildContext(version, [arch]); + const config = await readConfig(); + const refs = imageRefs(prefix, tagNames(config, version, channel)); + await $$`docker buildx build ${[ + ...["--platform", `linux/${arch}`], + "--load", + ...["--file", path.join(context, "docker", "Dockerfile")], + ...refs.flatMap((ref) => ["--tag", ref]), + context, + ]}`; + console.log(refs.join("\n")); +}; + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +} diff --git a/scripts/tasks/features/ripgrep/fetchPayload.ts b/scripts/tasks/features/ripgrep/fetchPayload.ts new file mode 100644 index 0000000..aeb77c8 --- /dev/null +++ b/scripts/tasks/features/ripgrep/fetchPayload.ts @@ -0,0 +1,26 @@ +import { parseArgs } from "node:util"; + +import { parseInvocation } from "./invocation.js"; +import { ensurePayload } from "./payload.js"; + +const main = async (): Promise => { + const { values } = parseArgs({ + options: { + version: { type: "string" }, + arch: { type: "string" }, + force: { type: "boolean", default: false }, + }, + }); + + const { version, arch } = parseInvocation(values); + await ensurePayload(version, arch, values.force); +}; + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +} diff --git a/scripts/tasks/features/ripgrep/generateConfig.ts b/scripts/tasks/features/ripgrep/generateConfig.ts new file mode 100644 index 0000000..f6997c9 --- /dev/null +++ b/scripts/tasks/features/ripgrep/generateConfig.ts @@ -0,0 +1,177 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import * as prettier from "prettier"; +import { z } from "zod"; + +import prettierOptions from "@/.config/prettier/.prettierrc.json" with { type: "json" }; +import { projectRoot } from "@/scripts/project.js"; + +const releasesUrl = "https://api.github.com/repos/BurntSushi/ripgrep/releases"; +export const configPath = path.resolve(projectRoot, "features/ripgrep/config.json"); + +export const targets = ["x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl"] as const; +export type Target = (typeof targets)[number]; + +type Version = readonly [number, number, number]; + +const minimumVersion: Version = [15, 2, 0]; + +const releaseSchema = z.object({ + tag_name: z.string(), + draft: z.boolean(), + prerelease: z.boolean(), + assets: z.array( + z.object({ + name: z.string(), + browser_download_url: z.string(), + digest: z.string().nullish(), + }), + ), +}); + +const releasesPageSchema = z.array(releaseSchema); + +const artifactSchema = z.object({ url: z.string(), sha256: z.string() }); + +const versionEntrySchema = z.object({ + aliases: z.array(z.string()), + "x86_64-unknown-linux-musl": artifactSchema, + "aarch64-unknown-linux-musl": artifactSchema, +}); + +export const featureConfigSchema = z.object({ + revision: z.number(), + versions: z.record(z.string(), versionEntrySchema), +}); + +export type Release = z.infer; +export type Artifact = z.infer; +export type VersionEntry = z.infer; +export type FeatureConfig = z.infer; + +interface Candidate { + tag: string; + number: Version; + release: Release; +} + +const versionOf = (tag: string): Version | undefined => { + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(tag); + if (match === null) { + return undefined; + } + return [Number(match[1]), Number(match[2]), Number(match[3])]; +}; + +const compare = (left: Version, right: Version): number => { + for (const [index, part] of left.entries()) { + if (part !== right[index]) { + return part - right[index]; + } + } + return 0; +}; + +const fetchReleases = async (): Promise => { + const releases: Release[] = []; + for (let page = 1; ; page++) { + const url = `${releasesUrl}?per_page=100&page=${page}`; + const response = await fetch(url, { headers: { accept: "application/vnd.github+json" } }); + if (!response.ok) { + throw new Error(`GET ${url} → HTTP ${response.status}`); + } + const result = releasesPageSchema.safeParse(await response.json()); + if (!result.success) { + throw new Error(`GET ${url}: ${z.prettifyError(result.error)}`); + } + if (result.data.length === 0) { + return releases; + } + releases.push(...result.data); + } +}; + +const trackedRelease = async (): Promise => { + const releases = await fetchReleases(); + const candidates = releases.flatMap((release) => { + if (release.draft || release.prerelease) { + return []; + } + const number = versionOf(release.tag_name); + if (number === undefined || compare(number, minimumVersion) < 0) { + return []; + } + return [{ tag: release.tag_name, number, release }]; + }); + if (candidates.length === 0) { + throw new Error(`No stable release at or above ${minimumVersion.join(".")}`); + } + return candidates.reduce((best, candidate) => (compare(candidate.number, best.number) > 0 ? candidate : best)); +}; + +const resolveArtifact = (candidate: Candidate, target: Target): Artifact => { + const name = `ripgrep-${candidate.tag}-${target}.tar.gz`; + const matches = candidate.release.assets.filter((asset) => asset.name === name); + if (matches.length !== 1) { + throw new Error(`Release ${candidate.tag}: expected one asset ${name}, found ${matches.length}`); + } + const [asset] = matches; + const match = /^sha256:([0-9a-f]{64})$/.exec(asset.digest ?? ""); + if (match === null) { + throw new Error(`Release ${candidate.tag}: asset ${name} has no sha256 digest (${asset.digest ?? "none"})`); + } + return { url: asset.browser_download_url, sha256: match[1] }; +}; + +const versionEntry = (candidate: Candidate): VersionEntry => { + const [major, minor] = candidate.number; + return { + aliases: [`${major}.${minor}`, `${major}`, "latest"], + "x86_64-unknown-linux-musl": resolveArtifact(candidate, "x86_64-unknown-linux-musl"), + "aarch64-unknown-linux-musl": resolveArtifact(candidate, "aarch64-unknown-linux-musl"), + }; +}; + +export const parseConfig = (text: string, source: string): FeatureConfig => { + const result = featureConfigSchema.safeParse(JSON.parse(text)); + if (!result.success) { + throw new Error(`${source}: ${z.prettifyError(result.error)}`); + } + return result.data; +}; + +export const readConfig = async (): Promise => + parseConfig(await readFile(configPath, "utf-8"), configPath); + +const readRevision = async (): Promise => { + try { + return (await readConfig()).revision; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return 1; + } + throw error; + } +}; + +export const generateConfig = async (): Promise => { + const candidate = await trackedRelease(); + const config: FeatureConfig = { + revision: await readRevision(), + versions: { [candidate.tag]: versionEntry(candidate) }, + }; + await writeFile( + configPath, + await prettier.format(JSON.stringify(config), { ...prettierOptions, filepath: configPath }), + ); +}; + +if (import.meta.main) { + try { + await generateConfig(); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +} diff --git a/scripts/tasks/features/ripgrep/invocation.ts b/scripts/tasks/features/ripgrep/invocation.ts new file mode 100644 index 0000000..4896a65 --- /dev/null +++ b/scripts/tasks/features/ripgrep/invocation.ts @@ -0,0 +1,26 @@ +import type { DockerArch } from "./paths.js"; +import { paths } from "./paths.js"; + +export interface Invocation { + version: string; + arch: DockerArch; +} + +export const parseVersion = (value: string | undefined): string => { + if (value === undefined || value === "") { + throw new Error("--version is required"); + } + return value; +}; + +export const parseInvocation = (values: { version?: string; arch?: string }): Invocation => { + const version = parseVersion(values.version); + const arch = + values.arch === undefined + ? paths.hostArch() + : paths.dockerArchOptions.find((candidate) => candidate === values.arch); + if (arch === undefined) { + throw new Error(`--arch must be one of: ${paths.dockerArchOptions.join(", ")}`); + } + return { version, arch }; +}; diff --git a/scripts/tasks/features/ripgrep/paths.ts b/scripts/tasks/features/ripgrep/paths.ts new file mode 100644 index 0000000..efcb2a3 --- /dev/null +++ b/scripts/tasks/features/ripgrep/paths.ts @@ -0,0 +1,52 @@ +import { stat } from "node:fs/promises"; +import path from "node:path"; + +import { workspaces } from "@/scripts/project.js"; + +import type { Target } from "./generateConfig.js"; + +const dockerArchOptions = ["amd64", "arm64"] as const; +export type DockerArch = (typeof dockerArchOptions)[number]; + +const hostArch = (): DockerArch => { + switch (process.arch) { + case "x64": + return "amd64"; + case "arm64": + return "arm64"; + default: + throw new Error(`Unsupported host architecture: ${process.arch}`); + } +}; + +const targets: Record = { + amd64: "x86_64-unknown-linux-musl", + arm64: "aarch64-unknown-linux-musl", +}; + +const payloadRoot = path.resolve(workspaces, "payload/ripgrep"); + +const payloadPath = (version: string, arch: DockerArch): string => path.join(payloadRoot, version, arch); + +const contextPath = (version: string): string => path.join(payloadRoot, "ctx", version); + +const isFile = async (candidate: string): Promise => { + try { + return (await stat(candidate)).isFile(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +}; + +export const paths = { + dockerArchOptions, + hostArch, + targets, + payloadRoot, + payloadPath, + contextPath, + isFile, +} as const; diff --git a/scripts/tasks/features/ripgrep/payload.ts b/scripts/tasks/features/ripgrep/payload.ts new file mode 100644 index 0000000..47b43a0 --- /dev/null +++ b/scripts/tasks/features/ripgrep/payload.ts @@ -0,0 +1,84 @@ +import { createHash } from "node:crypto"; +import { createWriteStream } from "node:fs"; +import { mkdir, rename, rm } from "node:fs/promises"; +import path from "node:path"; +import { Readable, Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +import { $$ } from "@/scripts/shell.js"; + +import { configPath, readConfig } from "./generateConfig.js"; +import type { DockerArch } from "./paths.js"; +import { paths } from "./paths.js"; + +const entries = [ + "rg", + "doc/rg.1", + "complete/rg.bash", + "complete/_rg", + "complete/rg.fish", + "COPYING", + "LICENSE-MIT", + "UNLICENSE", +] as const; + +const download = async (url: string, destination: string): Promise => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`GET ${url} → HTTP ${response.status}`); + } + if (response.body === null) { + throw new Error(`GET ${url}: no response body`); + } + const hash = createHash("sha256"); + const digest = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + hash.update(chunk); + callback(null, chunk); + }, + }); + await pipeline(Readable.fromWeb(response.body), digest, createWriteStream(destination)); + return hash.digest("hex"); +}; + +export const ensurePayload = async (version: string, arch: DockerArch, force: boolean): Promise => { + const outPath = paths.payloadPath(version, arch); + if (!force && (await paths.isFile(path.join(outPath, "rg")))) { + console.log(`Payload already present at ${outPath}; skipping fetch`); + return; + } + const config = await readConfig(); + const versionEntry = config.versions[version]; + if (versionEntry === undefined) { + throw new Error(`Version ${version} is not in ${configPath}`); + } + const target = paths.targets[arch]; + const { url, sha256 } = versionEntry[target]; + const tempPath = `${outPath}.tmp`; + await rm(tempPath, { recursive: true, force: true }); + const archivePath = path.join(tempPath, "archive.tar.gz"); + const extractedPath = path.join(tempPath, "extracted"); + await mkdir(extractedPath, { recursive: true }); + const actual = await download(url, archivePath); + if (actual !== sha256) { + throw new Error(`${url}: sha256 ${actual} does not match the configured ${sha256}`); + } + const top = `ripgrep-${version}-${target}`; + await $$`tar ${[ + "-xzf", + archivePath, + "-C", + extractedPath, + "--strip-components=1", + ...entries.map((entry) => `${top}/${entry}`), + ]}`; + for (const entry of entries) { + if (!(await paths.isFile(path.join(extractedPath, entry)))) { + throw new Error(`${url}: the archive has no ${entry}`); + } + } + await rm(outPath, { recursive: true, force: true }); + await rename(extractedPath, outPath); + await rm(tempPath, { recursive: true, force: true }); + console.log(`Payload written to ${outPath}`); +}; diff --git a/scripts/tasks/features/ripgrep/planPublish.ts b/scripts/tasks/features/ripgrep/planPublish.ts new file mode 100644 index 0000000..e17a4c5 --- /dev/null +++ b/scripts/tasks/features/ripgrep/planPublish.ts @@ -0,0 +1,39 @@ +import { parseArgs } from "node:util"; + +import { planPublish } from "@/scripts/tasks/features/planPublish.js"; +import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js"; + +import { readConfig } from "./generateConfig.js"; +import { canonicalTag, imageRef, tagNames } from "./tags.js"; + +const main = async (): Promise => { + const { values } = parseArgs({ + options: { + channel: { type: "string", default: mainChannel }, + out: { type: "string", default: ".ci/plan.json" }, + prefix: { type: "string" }, + }, + }); + + const channel = values.channel; + const prefix = values.prefix ?? (await defaultRefPrefix()); + const config = await readConfig(); + await planPublish( + Object.keys(config.versions).map((version) => ({ + label: version, + canonical: imageRef(prefix, canonicalTag(config, version, channel)), + tags: tagNames(config, version, channel), + entry: { version }, + })), + { channel, prefix, out: values.out }, + ); +}; + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +} diff --git a/scripts/tasks/features/ripgrep/publishImage.ts b/scripts/tasks/features/ripgrep/publishImage.ts new file mode 100644 index 0000000..f45a600 --- /dev/null +++ b/scripts/tasks/features/ripgrep/publishImage.ts @@ -0,0 +1,45 @@ +import { parseArgs } from "node:util"; + +import { pushImage } from "@/scripts/tasks/features/image.js"; +import { login } from "@/scripts/tasks/features/registry.js"; +import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js"; + +import { assembleBuildContext } from "./buildContext.js"; +import { readConfig } from "./generateConfig.js"; +import { parseVersion } from "./invocation.js"; +import { paths } from "./paths.js"; +import { ensurePayload } from "./payload.js"; +import { imageRefs, tagNames } from "./tags.js"; + +const main = async (): Promise => { + const { values } = parseArgs({ + options: { + version: { type: "string" }, + channel: { type: "string", default: mainChannel }, + prefix: { type: "string" }, + }, + }); + + const version = parseVersion(values.version); + const channel = values.channel; + const prefix = values.prefix ?? (await defaultRefPrefix()); + const config = await readConfig(); + const tags = tagNames(config, version, channel); + for (const arch of paths.dockerArchOptions) { + await ensurePayload(version, arch, false); + } + const context = await assembleBuildContext(version, paths.dockerArchOptions); + await login(prefix); + const refs = imageRefs(prefix, tags); + await pushImage(context, refs); + console.log(refs.join("\n")); +}; + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +} diff --git a/scripts/tasks/features/ripgrep/renovate.ts b/scripts/tasks/features/ripgrep/renovate.ts new file mode 100644 index 0000000..dc254c1 --- /dev/null +++ b/scripts/tasks/features/ripgrep/renovate.ts @@ -0,0 +1,59 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { parseArgs } from "node:util"; + +import git from "isomorphic-git"; + +import { projectRoot } from "@/scripts/project.js"; +import { refresh } from "@/scripts/tasks/features/renovate.js"; + +import type { FeatureConfig } from "./generateConfig.js"; +import { configPath, generateConfig, parseConfig, readConfig } from "./generateConfig.js"; + +const title = "Update ripgrep version pins"; +const configFilepath = path.relative(projectRoot, configPath); +const textDecoder = new TextDecoder(); + +const versionPins = (previous: FeatureConfig, next: FeatureConfig): string[] => { + const before = Object.keys(previous.versions); + const after = Object.keys(next.versions); + const removed = before.filter((version) => !after.includes(version)); + const added = after.filter((version) => !before.includes(version)); + if (removed.length === 0 && added.length === 0) { + return []; + } + return [`${removed.join(", ")} → ${added.join(", ")}`]; +}; + +const headConfig = async (oid: string): Promise => { + const { blob } = await git.readBlob({ fs, dir: projectRoot, oid, filepath: configFilepath }); + return parseConfig(textDecoder.decode(blob), `${configFilepath} at HEAD`); +}; + +const main = async (): Promise => { + const { values } = parseArgs({ + options: { + "dry-run": { type: "boolean", default: false }, + }, + }); + + await generateConfig(); + await refresh( + { + branch: "renovate/ripgrep", + title, + filepath: configFilepath, + body: async (headOid) => versionPins(await headConfig(headOid), await readConfig()).join("\n"), + }, + values["dry-run"], + ); +}; + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +} diff --git a/scripts/tasks/features/ripgrep/tagPublished.ts b/scripts/tasks/features/ripgrep/tagPublished.ts new file mode 100644 index 0000000..120c828 --- /dev/null +++ b/scripts/tasks/features/ripgrep/tagPublished.ts @@ -0,0 +1,29 @@ +import { parseArgs } from "node:util"; + +import { tagPublished } from "@/scripts/tasks/features/tagPublished.js"; +import { mainChannel } from "@/scripts/tasks/features/tags.js"; + +import { readConfig } from "./generateConfig.js"; +import { parseVersion } from "./invocation.js"; +import { canonicalTag } from "./tags.js"; + +const main = async (): Promise => { + const { values } = parseArgs({ + options: { + version: { type: "string" }, + }, + }); + + const version = parseVersion(values.version); + const config = await readConfig(); + await tagPublished(`features-ripgrep-${canonicalTag(config, version, mainChannel)}`); +}; + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +} diff --git a/scripts/tasks/features/ripgrep/tags.ts b/scripts/tasks/features/ripgrep/tags.ts new file mode 100644 index 0000000..c5d59eb --- /dev/null +++ b/scripts/tasks/features/ripgrep/tags.ts @@ -0,0 +1,24 @@ +import type { TagSource } from "@/scripts/tasks/features/tags.js"; +import { canonicalTag as baseCanonicalTag, tagNames as baseTagNames } from "@/scripts/tasks/features/tags.js"; + +import type { FeatureConfig } from "./generateConfig.js"; + +const sourceOf = (config: FeatureConfig, version: string): TagSource => { + const entry = config.versions[version]; + if (entry === undefined) { + const available = Object.keys(config.versions).join(", "); + throw new Error(`Version ${version} is not tracked (available: ${available})`); + } + return { version, revision: config.revision, aliases: entry.aliases }; +}; + +export const tagNames = (config: FeatureConfig, version: string, channel: string): string[] => + baseTagNames(sourceOf(config, version), channel); + +export const canonicalTag = (config: FeatureConfig, version: string, channel: string): string => + baseCanonicalTag(sourceOf(config, version), channel); + +export const imageRef = (prefix: string, tag: string): string => `${prefix}/ripgrep:${tag}`; + +export const imageRefs = (prefix: string, tags: readonly string[]): string[] => + tags.map((tag) => imageRef(prefix, tag)); diff --git a/scripts/tasks/features/tagPublished.ts b/scripts/tasks/features/tagPublished.ts new file mode 100644 index 0000000..02b1f6f --- /dev/null +++ b/scripts/tasks/features/tagPublished.ts @@ -0,0 +1,30 @@ +import fs from "node:fs/promises"; + +import { Octokit } from "@octokit/rest"; +import git from "isomorphic-git"; + +import { getRemoteInfo } from "@/scripts/git.js"; +import { projectRoot } from "@/scripts/project.js"; + +const isNotFound = (error: unknown): boolean => + typeof error === "object" && error !== null && "status" in error && error.status === 404; + +export const tagPublished = async (name: string): Promise => { + const token = process.env.GH_TOKEN; + if (token === undefined || token === "") { + throw new Error("GH_TOKEN environment variable is not set"); + } + const oid = await git.resolveRef({ fs, dir: projectRoot, ref: "HEAD" }); + const { owner, repo } = await getRemoteInfo(); + const octokit = new Octokit({ auth: token }); + try { + await octokit.rest.git.getRef({ owner, repo, ref: `tags/${name}` }); + console.log(`Tag ${name} already exists`); + } catch (error) { + if (!isNotFound(error)) { + throw error; + } + await octokit.rest.git.createRef({ owner, repo, ref: `refs/tags/${name}`, sha: oid }); + console.log(`Tagged ${name} at ${oid}`); + } +}; diff --git a/scripts/tasks/features/tags.ts b/scripts/tasks/features/tags.ts new file mode 100644 index 0000000..1a44074 --- /dev/null +++ b/scripts/tasks/features/tags.ts @@ -0,0 +1,60 @@ +import { getRemoteInfo } from "@/scripts/git.js"; + +export const mainChannel = "main"; + +export interface TagSource { + version: string; + revision: number; + aliases: readonly string[]; +} + +const tagPattern = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/; + +const channelSuffix = (channel: string): string => { + const suffix = channel.replaceAll(/[^a-zA-Z0-9._-]/gu, "-").replaceAll(/^[-.]+|[-.]+$/gu, ""); + if (suffix === "") { + throw new Error(`Channel ${channel} has an empty tag suffix`); + } + return suffix; +}; + +const mainTagNames = (source: TagSource): readonly string[] => [ + `${source.version}-r${source.revision}`, + source.version, + ...source.aliases, +]; + +const channelTagNames = (names: readonly string[], channel: string): readonly string[] => { + if (channel === mainChannel) { + return names; + } + const suffix = channelSuffix(channel); + const mainTags = new Set(names); + return names.map((name) => { + const tag = `${name}-${suffix}`; + if (mainTags.has(tag)) { + throw new Error(`Channel ${channel}: derived tag ${tag} is one of the pair's main channel tags`); + } + return tag; + }); +}; + +export const tagNames = (source: TagSource, channel: string): string[] => { + const names = channelTagNames(mainTagNames(source), channel); + return names.map((tag) => { + if (!tagPattern.test(tag)) { + throw new Error(`Channel ${channel}: derived tag ${tag} is not a valid registry tag`); + } + return tag; + }); +}; + +export const canonicalTag = (source: TagSource, channel: string): string => { + const [canonical] = tagNames(source, channel); + return canonical; +}; + +export const defaultRefPrefix = async (): Promise => { + const { owner, repo } = await getRemoteInfo(); + return `ghcr.io/${owner}/${repo}`; +}; diff --git a/scripts/verify/dotnet/assertions.ts b/scripts/verify/dotnet/assertions.ts index 001f4a7..3456148 100644 --- a/scripts/verify/dotnet/assertions.ts +++ b/scripts/verify/dotnet/assertions.ts @@ -20,7 +20,7 @@ const assertExec = (result: CommandResult, expectation: Expectation): void => { problems.push(`exit code ${result.exitCode ?? "none"}`); } if (!sameLines(order(stdout), order(expectation.stdout))) { - problems.push(`stdout ${JSON.stringify(stdout)} — expected ${JSON.stringify(expectation.stdout)}`); + problems.push(`stdout ${JSON.stringify(stdout)}, expected ${JSON.stringify(expectation.stdout)}`); } if (result.stderr.trim() !== "") { problems.push(`stderr ${JSON.stringify(result.stderr)}`); diff --git a/scripts/verify/dotnet/lifecycle.ts b/scripts/verify/dotnet/lifecycle.ts index f957664..7164994 100644 --- a/scripts/verify/dotnet/lifecycle.ts +++ b/scripts/verify/dotnet/lifecycle.ts @@ -6,7 +6,7 @@ import { $ } from "execa"; import { projectRoot } from "@/scripts/project.js"; import { project$$ } from "@/scripts/shell.js"; -import { login } from "@/scripts/tasks/features/dotnet/registry.js"; +import { login } from "@/scripts/tasks/features/registry.js"; import type { ComponentImage } from "./selection.js"; diff --git a/scripts/verify/dotnet/serve.ts b/scripts/verify/dotnet/serve.ts index f3d54c2..3c120de 100644 --- a/scripts/verify/dotnet/serve.ts +++ b/scripts/verify/dotnet/serve.ts @@ -47,11 +47,11 @@ const assertResponse = async (server: StartedCommand, { socketPath, expectedBody const response = await attempt(socketPath); if (response !== undefined) { if (response.statusCode !== 200) { - throw new Error(`GET ${socketPath} returned HTTP ${String(response.statusCode)} — expected 200`); + throw new Error(`GET ${socketPath} returned HTTP ${String(response.statusCode)}, expected 200`); } if (response.body !== expectedBody) { throw new Error( - `GET ${socketPath} returned ${JSON.stringify(response.body)} — expected ${JSON.stringify(expectedBody)}`, + `GET ${socketPath} returned ${JSON.stringify(response.body)}, expected ${JSON.stringify(expectedBody)}`, ); } return; diff --git a/scripts/verify/dotnet/verify.ts b/scripts/verify/dotnet/verify.ts index 9aa0d1d..6258aa9 100644 --- a/scripts/verify/dotnet/verify.ts +++ b/scripts/verify/dotnet/verify.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { parseArgs } from "node:util"; import type { Component } from "@/scripts/tasks/features/dotnet/generateConfig.js"; -import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/dotnet/tags.js"; +import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js"; import { assertions } from "./assertions.js"; import type { TestImage, Workspace } from "./lifecycle.js"; diff --git a/scripts/verify/ripgrep/assertions.ts b/scripts/verify/ripgrep/assertions.ts new file mode 100644 index 0000000..3456148 --- /dev/null +++ b/scripts/verify/ripgrep/assertions.ts @@ -0,0 +1,34 @@ +import type { CommandResult } from "./lifecycle.js"; + +export interface Expectation { + stdout: readonly string[]; + sorted?: boolean; +} + +const linesOf = (text: string): string[] => (text.trim() === "" ? [] : text.trimEnd().split("\n")); + +const sameLines = (actual: readonly string[], expected: readonly string[]): boolean => + actual.length === expected.length && actual.every((line, index) => line === expected[index]); + +const assertExec = (result: CommandResult, expectation: Expectation): void => { + const stdout = linesOf(result.stdout); + const sorted = expectation.sorted === true; + const order = (lines: readonly string[]): string[] => (sorted ? [...lines].sort() : [...lines]); + + const problems: string[] = []; + if (result.exitCode !== 0) { + problems.push(`exit code ${result.exitCode ?? "none"}`); + } + if (!sameLines(order(stdout), order(expectation.stdout))) { + problems.push(`stdout ${JSON.stringify(stdout)}, expected ${JSON.stringify(expectation.stdout)}`); + } + if (result.stderr.trim() !== "") { + problems.push(`stderr ${JSON.stringify(result.stderr)}`); + } + if (problems.length === 0) { + return; + } + throw new Error(`devcontainer exec ${result.command.join(" ")}:\n${problems.join("\n")}`); +}; + +export const assertions = { exec: assertExec } as const; diff --git a/scripts/verify/ripgrep/lifecycle.ts b/scripts/verify/ripgrep/lifecycle.ts new file mode 100644 index 0000000..7f57b86 --- /dev/null +++ b/scripts/verify/ripgrep/lifecycle.ts @@ -0,0 +1,165 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import path, { posix } from "node:path"; + +import { $ } from "execa"; + +import { projectRoot } from "@/scripts/project.js"; +import { project$$ } from "@/scripts/shell.js"; +import { login } from "@/scripts/tasks/features/registry.js"; + +const baseImage = "mcr.microsoft.com/devcontainers/base:debian"; +const remoteUser = "verify"; +export const testImagePrefix = "features-verify-ripgrep"; + +const $$docker = $({ reject: false, stdin: "ignore", stderr: "ignore" }); +const $$capture = $({ reject: false, stdio: ["ignore", "pipe", "pipe"], verbose: "full", cwd: projectRoot }); + +export interface Workspace { + path: string; + containerPath: string; +} + +export interface Command { + command: readonly string[]; +} + +export interface CommandResult { + command: readonly string[]; + exitCode: number | undefined; + stdout: string; + stderr: string; +} + +export interface WorkspaceConfig { + image: string; +} + +const workspaceAt = (root: string, name: string): Workspace => { + const directory = `workspace-${name}`; + return { path: path.join(root, directory), containerPath: posix.join("/workspaces", directory) }; +}; + +const workspaceImageName = (workspace: Workspace): string => { + const folderHash = createHash("sha256").update(workspace.path).digest("hex"); + return `vsc-${path.basename(workspace.path)}-${folderHash}`; +}; + +const workspaceImages = async (workspace: Workspace): Promise => { + const name = workspaceImageName(workspace); + const { stdout } = await $$docker`docker images ${["--format", "{{.Repository}}:{{.Tag}}"]}`; + return stdout.split("\n").filter((tag) => tag.startsWith(name)); +}; + +export const imagesModes = ["build", "pull"] as const; +export type ImagesMode = (typeof imagesModes)[number]; + +export interface ProvisionOptions { + images: ImagesMode; + version: string; + ref: string; + channel: string; + prefix: string; + force: boolean; +} + +const provision = async (options: ProvisionOptions): Promise => { + if (options.images === "pull") { + await login(options.prefix); + await project$$`docker pull ${options.ref}`; + } else { + await project$$`tsx scripts/tasks/features/ripgrep/fetchPayload.ts ${[ + ...["--version", options.version], + ...(options.force ? ["--force"] : []), + ]}`; + await project$$`tsx scripts/tasks/features/ripgrep/buildImage.ts ${[ + ...["--version", options.version], + ...["--channel", options.channel], + ...["--prefix", options.prefix], + ]}`; + } + const inspect = await $$docker`docker image inspect ${baseImage}`; + if (inspect.exitCode !== 0) { + await project$$`docker pull ${baseImage}`; + } +}; + +const buildTestImage = async (ctxPath: string, version: string, ref: string): Promise => { + await mkdir(ctxPath, { recursive: true }); + await project$$`docker buildx build ${[ + "--network=none", + "--load", + ...["--tag", `${testImagePrefix}:${version}`], + ...["--file", path.join(import.meta.dirname, "test-image.Dockerfile")], + ...["--build-arg", `RIPGREP_IMAGE_REF=${ref}`], + ctxPath, + ]}`; +}; + +const createWorkspace = async (workspace: Workspace, config: WorkspaceConfig): Promise => { + const devcontainerPath = path.join(workspace.path, ".devcontainer"); + await mkdir(devcontainerPath, { recursive: true }); + const data = { + image: config.image, + features: { "./features": {}, "./user-init": {} }, + remoteUser, + containerUser: remoteUser, + runArgs: ["--network=none"], + }; + await writeFile(path.join(devcontainerPath, "devcontainer.json"), `${JSON.stringify(data, null, 2)}\n`); +}; + +const buildContainer = async (workspace: Workspace): Promise => { + await project$$`devcontainer build ${["--workspace-folder", workspace.path]}`; +}; + +const startContainer = async (workspace: Workspace): Promise => { + await project$$`devcontainer up ${["--remove-existing-container", ...["--workspace-folder", workspace.path]]}`; + if ((await workspaceImages(workspace)).length === 0) { + throw new Error( + `devcontainer up ${workspace.path}: no images matching ${workspaceImageName(workspace)}*; the devcontainer CLI folder image naming may have changed`, + ); + } +}; + +const execInContainer = async (workspace: Workspace, command: Command): Promise => { + const result = await $$capture`devcontainer exec ${["--workspace-folder", workspace.path]} ${command.command}`; + return { command: command.command, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; +}; + +const removeContainer = async (workspace: Workspace): Promise => { + const filter = `label=devcontainer.config_file=${path.join(workspace.path, ".devcontainer/devcontainer.json")}`; + const { stdout } = await $$docker`docker ps -a ${[...["--filter", filter], ...["--format", "{{.ID}}"]]}`; + const ids = [...new Set(stdout.split("\n").filter(Boolean))]; + if (ids.length > 0) { + await $$docker`docker rm --force ${ids}`; + } +}; + +const removeImage = async (tag: string): Promise => { + const inspect = await $$docker`docker image inspect ${tag}`; + if (inspect.exitCode !== 0) { + return; + } + await project$$`docker image rm ${tag}`; +}; + +const removeWorkspaceImages = async (workspace: Workspace): Promise => { + const tags = await workspaceImages(workspace); + if (tags.length > 0) { + await project$$`docker image rm ${tags}`; + } +}; + +export const lifecycle = { + provision, + buildTestImage, + createWorkspace, + workspaceAt, + buildContainer, + startContainer, + execInContainer, + removeContainer, + removeImage, + removeWorkspaceImages, +} as const; diff --git a/scripts/verify/ripgrep/selection.ts b/scripts/verify/ripgrep/selection.ts new file mode 100644 index 0000000..6a1b18d --- /dev/null +++ b/scripts/verify/ripgrep/selection.ts @@ -0,0 +1,24 @@ +import type { FeatureConfig } from "@/scripts/tasks/features/ripgrep/generateConfig.js"; +import { configPath, readConfig } from "@/scripts/tasks/features/ripgrep/generateConfig.js"; +import { canonicalTag, imageRef } from "@/scripts/tasks/features/ripgrep/tags.js"; + +export interface Selection { + version: string; + ref: string; +} + +const latestVersion = (config: FeatureConfig): string => { + const matches = Object.keys(config.versions).filter((version) => config.versions[version].aliases.includes("latest")); + if (matches.length !== 1) { + throw new Error(`${configPath}: exactly one version must carry the latest alias, found ${matches.length}`); + } + return matches[0]; +}; + +const derive = async (channel: string, prefix: string): Promise => { + const config = await readConfig(); + const version = latestVersion(config); + return { version, ref: imageRef(prefix, canonicalTag(config, version, channel)) }; +}; + +export const selection = { derive } as const; diff --git a/scripts/verify/ripgrep/staging.ts b/scripts/verify/ripgrep/staging.ts new file mode 100644 index 0000000..84657d9 --- /dev/null +++ b/scripts/verify/ripgrep/staging.ts @@ -0,0 +1,16 @@ +import { cp } from "node:fs/promises"; +import path from "node:path"; + +import { projectRoot } from "@/scripts/project.js"; + +import type { Workspace } from "./lifecycle.js"; + +const featureSourcePath = path.join(projectRoot, ".devcontainer/features/src"); + +const stageFeatures = async (workspace: Workspace): Promise => { + const devcontainerPath = path.join(workspace.path, ".devcontainer"); + await cp(path.join(featureSourcePath, "features"), path.join(devcontainerPath, "features"), { recursive: true }); + await cp(path.join(featureSourcePath, "user-init"), path.join(devcontainerPath, "user-init"), { recursive: true }); +}; + +export const staging = { features: stageFeatures } as const; diff --git a/scripts/verify/ripgrep/test-image.Dockerfile b/scripts/verify/ripgrep/test-image.Dockerfile new file mode 100644 index 0000000..400aba9 --- /dev/null +++ b/scripts/verify/ripgrep/test-image.Dockerfile @@ -0,0 +1,7 @@ +# check=skip=InvalidDefaultArgInFrom +ARG RIPGREP_IMAGE_REF + +FROM ${RIPGREP_IMAGE_REF} AS ripgrep + +FROM mcr.microsoft.com/devcontainers/base:debian +COPY --from=ripgrep /features/ripgrep/ /opt/devcontainer-config/features/ripgrep/ diff --git a/scripts/verify/ripgrep/verify.ts b/scripts/verify/ripgrep/verify.ts new file mode 100644 index 0000000..0933b00 --- /dev/null +++ b/scripts/verify/ripgrep/verify.ts @@ -0,0 +1,114 @@ +import { mkdtempDisposable } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { parseArgs } from "node:util"; + +import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js"; + +import type { Expectation } from "./assertions.js"; +import { assertions } from "./assertions.js"; +import { imagesModes, lifecycle, testImagePrefix } from "./lifecycle.js"; +import { selection } from "./selection.js"; +import { staging } from "./staging.js"; + +interface Exec { + command: readonly string[]; + expect: Expectation; +} + +const installedFiles = [ + "/usr/local/share/man/man1/rg.1", + "/usr/local/share/bash-completion/completions/rg", + "/usr/local/share/zsh/site-functions/_rg", + "/usr/local/share/fish/vendor_completions.d/rg.fish", + "/usr/local/share/doc/ripgrep/COPYING", + "/usr/local/share/doc/ripgrep/LICENSE-MIT", + "/usr/local/share/doc/ripgrep/UNLICENSE", +]; + +const defaultExecs = (version: string): Exec[] => [ + { + command: ["sh", "-c", "rg --version | awk 'NR == 1 { print $1, $2 }'"], + expect: { stdout: [`ripgrep ${version}`] }, + }, + { + command: ["sh", "-c", "rg --version | grep -Fx 'features:+pcre2'"], + expect: { stdout: ["features:+pcre2"] }, + }, + { + command: ["sh", "-c", "command -v rg"], + expect: { stdout: ["/usr/local/bin/rg"] }, + }, + { + command: ["sh", "-c", "printf 'alpha\\nbeta\\n' | rg beta"], + expect: { stdout: ["beta"] }, + }, + { + command: [ + "sh", + "-c", + `for file in ${installedFiles.join(" ")}; do test -s "$file" || { echo "missing $file"; exit 1; }; done; echo present`, + ], + expect: { stdout: ["present"] }, + }, +]; + +export const verify = async (argv: string[]): Promise => { + const { values } = parseArgs({ + args: argv, + options: { + force: { type: "boolean", default: false }, + rmi: { type: "boolean", default: false }, + images: { type: "string", default: "build" }, + channel: { type: "string", default: mainChannel }, + prefix: { type: "string" }, + }, + }); + + const images = imagesModes.find((candidate) => candidate === values.images); + if (images === undefined) { + throw new Error(`--images must be one of: ${imagesModes.join(", ")}`); + } + const channel = values.channel; + const prefix = values.prefix ?? (await defaultRefPrefix()); + const derived = await selection.derive(channel, prefix); + console.log(`>>> verify ripgrep: selection version=${derived.version} ref=${derived.ref}`); + + const tempPath = await mkdtempDisposable(path.join(tmpdir(), "devcontainer-verify-ripgrep-")); + const workspace = lifecycle.workspaceAt(tempPath.path, "default"); + const testImage = `${testImagePrefix}:${derived.version}`; + + try { + console.log(">>> verify ripgrep: provision"); + await lifecycle.provision({ + images, + version: derived.version, + ref: derived.ref, + channel, + prefix, + force: values.force, + }); + + console.log(">>> verify ripgrep: test image"); + await lifecycle.buildTestImage(path.join(tempPath.path, "ctx"), derived.version, derived.ref); + + console.log(">>> verify ripgrep: workspace"); + await lifecycle.createWorkspace(workspace, { image: testImage }); + await staging.features(workspace); + + console.log(">>> verify ripgrep: scenario default"); + await lifecycle.buildContainer(workspace); + await lifecycle.startContainer(workspace); + for (const exec of defaultExecs(derived.version)) { + assertions.exec(await lifecycle.execInContainer(workspace, exec), exec.expect); + } + } finally { + console.log(">>> verify ripgrep: cleanup"); + await lifecycle.removeContainer(workspace); + await lifecycle.removeWorkspaceImages(workspace); + if (values.rmi) { + await lifecycle.removeImage(testImage); + } + await tempPath.remove(); + } +};