diff --git a/.gitattributes b/.gitattributes index 2e2c7b2..00f4c25 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ -dist/** -diff linguist-generated=true \ No newline at end of file +* text=auto eol=lf +dist/** -diff linguist-generated=true diff --git a/.github/linters/.markdown-lint.yml b/.github/linters/.markdown-lint.yml index 6d79773..714b1cc 100644 --- a/.github/linters/.markdown-lint.yml +++ b/.github/linters/.markdown-lint.yml @@ -5,3 +5,7 @@ MD004: # Ordered list item prefix MD029: style: one + +# Selector and input reference tables keep one entry per row. +MD013: + tables: false diff --git a/.github/workflows/check-dist.yml b/.github/workflows/check-dist.yml index 71332d9..75ff1fb 100644 --- a/.github/workflows/check-dist.yml +++ b/.github/workflows/check-dist.yml @@ -19,6 +19,10 @@ on: - '**.md' workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: check-dist: name: Check dist/ @@ -36,7 +40,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 18 + node-version: '24' cache: npm - name: Install Dependencies diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad1a6fa..ab11dd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,108 +1,104 @@ name: Continuous Integration - on: pull_request: push: - branches: - - main - - 'releases/*' - + branches: [main, 'releases/*'] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: test-typescript: - name: TypeScript Tests - runs-on: ubuntu-latest - + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node: ['20', '24'] + runs-on: ${{ matrix.os }} steps: - - name: Checkout - id: checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - id: setup-node - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: - node-version: 18 + node-version: ${{ matrix.node }} cache: npm - - - name: Install Dependencies - id: npm-ci - run: npm ci - - - name: Check Format - id: npm-format-check - run: npm run format:check - - - name: Lint - id: npm-lint - run: npm run lint - - - name: Test - id: npm-ci-test - run: npm run ci-test - - test-action: - name: GitHub Actions Test + - run: npm ci --ignore-scripts + - run: npm run format:check + - run: npm run lint + - run: npx tsc --noEmit + - run: npm run ci-test + native: + name: ${{ matrix.method }} / ${{ matrix.platform.os }} / ${{ matrix.platform.arch }} / ${{ matrix.platform.abi }} + timeout-minutes: 60 strategy: + fail-fast: false matrix: - os: [ubuntu-24.04, macos-latest, windows-latest] - - runs-on: ${{ matrix.os }} - + method: [release, source] + platform: + - {os: ubuntu-24.04, arch: amd64, abi: msvc} + - {os: ubuntu-24.04-arm, arch: arm64, abi: msvc} + - {os: macos-15-intel, arch: amd64, abi: msvc} + - {os: macos-latest, arch: arm64, abi: msvc} + - {os: windows-2022, arch: amd64, abi: msvc} + - {os: windows-2022, arch: amd64, abi: mingw} + - {os: windows-11-arm, arch: arm64, abi: msvc} + - {os: windows-11-arm, arch: arm64, abi: mingw} + runs-on: ${{ matrix.platform.os }} steps: - - name: Checkout - id: checkout - uses: actions/checkout@v4 - - - name: Test Local Action - id: test-action - continue-on-error: ${{ matrix.os == 'windows-latest' }} - uses: ./ + - uses: actions/checkout@v7 + - uses: ./ + id: setup + with: + llgo-version: ${{ matrix.method == 'release' && 'v1.0.4' || 'main' }} + install-method: ${{ matrix.method }} + architecture: ${{ matrix.platform.arch }} + windows-abi: ${{ matrix.platform.abi }} + - name: Check installation outputs and execute native code + shell: bash + working-directory: testdata/smoke + env: + ACTUAL_METHOD: ${{ steps.setup.outputs.install-method }} + ACTUAL_VERIFIED: ${{ steps.setup.outputs.llgo-version-verified }} + EXPECTED_VERIFIED: ${{ matrix.method == 'release' && 'true' || 'false' }} + EXPECTED_METHOD: ${{ matrix.method }} + LLGO_REVISION: ${{ steps.setup.outputs.llgo-revision }} + LLGO_ARCH: ${{ matrix.platform.arch }} + LLGO_ABI: ${{ matrix.platform.abi }} + run: | + test "$ACTUAL_METHOD" = "$EXPECTED_METHOD" + test "$ACTUAL_VERIFIED" = "$EXPECTED_VERIFIED" + test "${#LLGO_REVISION}" = 40 + llgo version + llgo test -v ./... + llgo build -x -o hello.exe . > build.log 2>&1 + test "$(./hello.exe | tr -d '\r')" = 42 + if [[ "$RUNNER_OS" == Windows ]]; then + arch=x86_64 + [[ "$LLGO_ARCH" == arm64 ]] && arch=aarch64 + if [[ "$LLGO_ABI" == msvc ]]; then + grep -q "$arch-pc-windows-msvc" build.log + else + grep -Eq "$arch-(w64-windows-gnu|w64-mingw32)" build.log + fi + fi + - name: Upload build diagnostics + if: failure() + uses: actions/upload-artifact@v7 with: - llgo-version: '0.9.8' - - - name: Print Output - id: output - run: echo "${{ steps.test-action.outputs.llgo-version }}" - - test-versions: - name: LLGo Versions Test + name: logs-${{ matrix.method }}-${{ matrix.platform.os }}-${{ matrix.platform.arch }}-${{ matrix.platform.abi }} + path: testdata/smoke/build.log + selectors: + name: Selector / ${{ matrix.version }} + runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - llgo-version: ['0.9.8', '', 'latest', 'main', 'v0.9'] - include: - - llgo-version: '0.9.8' - llgo-version-verified: 'false' - - llgo-version: '' - llgo-version-verified: 'false' - - llgo-version: 'latest' - llgo-version-verified: 'false' - - llgo-version: 'main' - llgo-version-verified: 'false' - - llgo-version: 'v0.9' - llgo-version-verified: 'false' - - runs-on: ubuntu-24.04 - + version: ['v1.0', 'v1.0.*', '^1.0.3', '7db1409073f28bf13a35ae0a3ea663eba010fd73', '7db1409'] steps: - - name: Checkout - id: checkout - uses: actions/checkout@v4 - - - name: Test LLGo Version - id: test-llgo-version - uses: ./ + - uses: actions/checkout@v7 + - uses: ./ with: - llgo-version: ${{ matrix.llgo-version }} - - # - name: Test version verified - # env: - # VERIFIED: ${{ matrix.llgo-version-verified }} - # LLGO_VERSION: ${{ matrix.llgo-version }} - # run: | - # echo "llgo-version: $LLGO_VERSION" - # echo "llgo-version-verified: $VERIFIED" - # if [[ "$VERIFIED" != "$VERIFIED" ]]; then - # echo "llgo-version-verified does not match expected value" - # echo "expected: $VERIFIED, got: $VERIFIED" - # exit 1 - # fi + llgo-version: ${{ matrix.version }} + - run: llgo test -v ./... + working-directory: testdata/smoke diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 57a0784..0442d51 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -8,6 +8,10 @@ on: branches-ignore: - main +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: lint: name: Lint Code Base @@ -16,7 +20,6 @@ jobs: permissions: contents: read packages: read - statuses: write steps: - name: Checkout @@ -27,7 +30,7 @@ jobs: id: setup-node uses: actions/setup-node@v4 with: - node-version: 18 + node-version: '24' cache: npm - name: Install Dependencies @@ -42,4 +45,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TYPESCRIPT_DEFAULT_STYLE: prettier VALIDATE_JSCPD: false + # LLGo smoke fixtures are compiled together by the native CI matrix. + # Super-linter v5 invokes Go type checking separately on each file. + VALIDATE_GO: false + MULTI_STATUS: false FILTER_REGEX_EXCLUDE: dist/* diff --git a/LICENSES/llgo.txt b/LICENSES/llgo.txt new file mode 100644 index 0000000..55d10a6 --- /dev/null +++ b/LICENSES/llgo.txt @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright (c) 2021 The XGo Authors (xgo.dev). All rights reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index 5ca636b..0b46cb0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,89 @@ # setup-llgo -Set up your GitHub Actions workflow with a specific version of LLGo + +Install LLGo on Linux, macOS and Windows. Releases use SHA-256-verified +precompiled archives when available; branches and commits build from source. +Go, LLVM and native runtime dependencies are installed for the selected host. + +```yaml +- uses: actions/checkout@v7 +- uses: xgo-dev/setup-llgo@main + with: + llgo-version: main + go-version: '1.27' + llvm-version: '22' +- run: llgo test -v ./... +``` + +## Selecting LLGo + +| Input | Selection | +| --- | --- | +| `v1.0.4` or `1.0.4` | Exact tag | +| `v1.0`, `1.0`, `1` | Highest stable version with that prefix | +| `1.0.x`, `v1.0.*`, `^1.0.3`, `>=1.0 <1.1` | Highest version satisfying the SemVer range | +| `v1.0.?`, `v2.0.0-rc.*` | Matching tag using `*` and `?` globs | +| `main`, `release/1.0` | Exact branch | +| `release/*` | A unique matching branch; ambiguous matches fail | +| `refs/heads/main`, `refs/tags/v1.0.4` | Explicit ref | +| Full commit SHA or 7+ hexadecimal characters | Exact commit; unknown/ambiguous abbreviations fail | +| Empty or `latest` | Highest stable release tag | + +Exact tags take precedence over exact branches; explicit `refs/heads/` removes +ambiguity. Version patterns select by semantic version, not lexicographic order. +Prereleases require an explicit prerelease version/range or a matching tag glob. +Annotated tags resolve to their commit. Abbreviated commits require fetching +history, so full SHAs are faster. Branches and version patterns are resolved +remotely on every run; the resolved full SHA is logged and available as output. + +## Inputs + +| Input | Default | Purpose | +| --- | --- | --- | +| `llgo-version` | Empty (latest stable tag) | Selector described above | +| `llgo-version-file` | Empty | Plain selector file or `// llgo ` in go.mod/go.work; explicit version takes precedence | +| `install-method` | `auto` | `auto`, `release`, or `source` | +| `go-version` | `1.27` when neither Go input is supplied | Go toolchain version | +| `go-version-file` | Empty | Version file passed to actions/setup-go | +| `llvm-version` | `22` | LLVM major version; Windows currently uses 22 | +| `architecture` | Runner architecture | Native `amd64`/`x64` or `arm64` | +| `windows-abi` | `msvc` | `msvc` or `mingw` | +| `install-dependencies` | `true` | Set `false` when the matching LLVM/SDK/runtime dependencies are already configured | +| `cache` | `true` | Restore/save Go module and build caches | +| `cache-dependency-path` | `go.sum` | Additional project dependency files for the cache key | +| `check-latest` | `false` | Check for the latest matching Go toolchain | +| `token` | `github.token` | GitHub API and Go download authentication | + +`auto` downloads the matching release asset if one exists, otherwise builds the +resolved commit. `release` fails if the asset is unavailable; `source` always +builds from source. Network/authentication failures and checksum mismatches fail +installation instead of silently switching methods. Older LLGo versions may need +explicit compatible Go and LLVM versions. + +Release metadata and archive transfers retry transient connection failures and +timeouts up to three attempts, with 1s/2s backoff. HTTP errors, invalid checksums +and local filesystem failures fail immediately. + +The installation stays in a unique directory under `RUNNER_TEMP`; existing user +work directories are never removed. `PATH` and `LLGO_ROOT` are exported for later +steps. Go caching includes LLGo's dependency files and, for source builds, its +resolved Git HEAD. The action requires Node.js 20+ and Git on `PATH`; CI tests +Node.js 20 and 24 on Linux, macOS and Windows. Self-hosted runners must also +provide `tar` (including ZIP support on Windows). + +## Platforms and validation + +CI installs both a release archive and `main` on all eight native combinations: + +- Linux amd64 and arm64 (Ubuntu/glibc). +- macOS amd64 and arm64. +- Windows amd64 and arm64, each with MSVC and MinGW ABIs. + +Each combination runs `llgo test`, builds and executes a sample program, and +Windows jobs check the native target triple. Separate jobs test version prefixes, +ranges, glob patterns, full commits and abbreviated commits. Unit tests use local +Git fixtures and mocked downloads; they never install software into the user's +home directory. The Windows setup is adapted from LLGo's own CI; see +`THIRD_PARTY_NOTICES.md`. + +Outputs: `llgo-version`, `llgo-version-verified` (whether a tag was selected), +`llgo-revision`, `llgo-ref`, `install-method`, `go-version`, and `cache-hit`. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..f82bb44 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,10 @@ +# Third-party notices + +The Windows dependency and ABI activation steps in `action.yml`, +`scripts/windows/`, and `scripts/pacman_retry*.sh` are adapted from +[xgo-dev/llgo at 7db1409](https://github.com/xgo-dev/llgo/tree/7db1409073f28bf13a35ae0a3ea663eba010fd73/.github). +They are licensed under Apache-2.0; see `LICENSES/llgo.txt`. +Paths were adjusted to resolve within this action, and the native installation +matrix excludes the upstream 32-bit cross-target setup. + +Bundled JavaScript dependency notices are in `dist/licenses.txt`. diff --git a/__tests__/install.test.ts b/__tests__/install.test.ts new file mode 100644 index 0000000..7896344 --- /dev/null +++ b/__tests__/install.test.ts @@ -0,0 +1,160 @@ +import { execFileSync } from 'child_process' +import { createHash } from 'crypto' +import fs from 'fs' +import os from 'os' +import path from 'path' +import * as core from '@actions/core' +import * as downloads from '../src/download' +import { checkoutLLGo, installRelease, archiveTool } from '../src/install' +import { parseRemoteRefs, resolveVersion, Selection } from '../src/resolve' + +let root: string +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-llgo-test-')) +}) +afterEach(() => { + jest.restoreAllMocks() + fs.rmSync(root, { recursive: true, force: true }) +}) +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} +it('checks out a tag, a branch, full SHA and an old abbreviated SHA in isolated paths', () => { + const repo = path.join(root, 'repo') + fs.mkdirSync(repo) + git(['init', '-b', 'main'], repo) + git(['config', 'user.name', 'test'], repo) + git(['config', 'user.email', 'test@example.invalid'], repo) + fs.writeFileSync(path.join(repo, 'fixture'), 'first') + git(['add', '.'], repo) + git(['commit', '-m', 'first'], repo) + const first = git(['rev-parse', 'HEAD'], repo) + git(['tag', '-a', 'v1.0.4', '-m', 'release'], repo) + fs.writeFileSync(path.join(repo, 'fixture'), 'second') + git(['commit', '-am', 'second'], repo) + const head = git(['rev-parse', 'HEAD'], repo) + const refs = parseRemoteRefs( + git(['ls-remote', '--heads', '--tags', repo], root) + ) + const sentinel = path.join(root, 'workdir') + fs.mkdirSync(sentinel) + fs.writeFileSync(path.join(sentinel, 'keep'), 'untouched') + const tagsBySpec = new Map() + for (const [spec, want] of [ + ['v1.0.4', first], + ['main', head], + [first, first], + [first.slice(0, 8), first] + ]) { + const dest = fs.mkdtempSync(path.join(root, 'install 空格 ')) + expect(checkoutLLGo(resolveVersion(spec, refs), dest, repo)).toBe(want) + expect(git(['rev-parse', 'HEAD'], dest)).toBe(want) + tagsBySpec.set(spec, git(['tag', '--points-at', 'HEAD'], dest)) + } + expect(tagsBySpec.get('v1.0.4')).toBe('v1.0.4') + expect(fs.readFileSync(path.join(sentinel, 'keep'), 'utf8')).toBe('untouched') +}) + +const release: Selection = { + kind: 'tag', + ref: 'refs/tags/v1.0.4', + sha: 'a'.repeat(40) +} +const platform = { os: 'linux', arch: 'amd64', abi: '' } +it('falls back only when a release or matching asset is absent', async () => { + jest.spyOn(core, 'getInput').mockReturnValue('') + const fetchMock = jest.spyOn(global, 'fetch') + fetchMock.mockResolvedValueOnce(new Response('', { status: 404 })) + expect(await installRelease(release, platform, root)).toBe(false) + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ assets: [] }))) + expect(await installRelease(release, platform, root)).toBe(false) + expect( + await installRelease({ ...release, kind: 'branch' }, platform, root) + ).toBe(false) + expect(fetchMock).toHaveBeenCalledTimes(2) +}) +it('does not hide API failures behind source fallback', async () => { + jest.spyOn(core, 'getInput').mockReturnValue('') + const fetchMock = jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response('', { status: 403 })) + await expect(installRelease(release, platform, root)).rejects.toThrow( + 'HTTP 403' + ) + expect(fetchMock).toHaveBeenCalledTimes(1) +}) +it('retries metadata connection failures but accepts a 404 without retrying it', async () => { + jest.spyOn(core, 'getInput').mockReturnValue('') + const fetchMock = jest.spyOn(global, 'fetch') + fetchMock.mockRejectedValueOnce( + new TypeError('fetch failed', { + cause: Object.assign(new Error('reset'), { code: 'ECONNRESET' }) + }) + ) + fetchMock.mockResolvedValueOnce(new Response('', { status: 404 })) + expect(await installRelease(release, platform, root)).toBe(false) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal) +}) +it('verifies and extracts a matching precompiled release', async () => { + jest.spyOn(core, 'getInput').mockReturnValue('') + jest.spyOn(core, 'info').mockImplementation(() => {}) + const name = 'llgo1.0.4.linux-amd64.tar.gz' + const payload = path.join(root, 'payload') + fs.mkdirSync(payload) + fs.mkdirSync(path.join(payload, 'bin')) + fs.writeFileSync(path.join(payload, 'bin/llgo'), 'compiler') + const archive = path.join(root, 'fixture.tar.gz') + execFileSync(archiveTool(), ['-czf', archive, '-C', payload, '.']) + const checksum = createHash('sha256') + .update(fs.readFileSync(archive)) + .digest('hex') + jest.spyOn(global, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + assets: [ + { name, browser_download_url: 'https://example.invalid/archive' }, + { + name: 'llgo1.0.4.checksums.txt', + browser_download_url: 'https://example.invalid/checksums' + } + ] + }) + ) + ) + jest.spyOn(downloads, 'download').mockImplementation(async (url, dest) => { + if (url.endsWith('checksums')) + fs.writeFileSync(dest, `${checksum} ${name}\n`) + else fs.copyFileSync(archive, dest) + }) + const dest = path.join(root, 'installed') + fs.mkdirSync(dest) + expect(await installRelease(release, platform, dest)).toBe(true) + expect(fs.readFileSync(path.join(dest, 'bin/llgo'), 'utf8')).toBe('compiler') +}) +it('rejects corrupt or unlisted archives', async () => { + const archive = path.join(root, 'archive') + fs.writeFileSync(archive, 'bad') + await expect( + downloads.verifyChecksum(archive, 'archive', `${'0'.repeat(64)} archive`) + ).rejects.toThrow('mismatch') + await expect( + downloads.verifyChecksum(archive, 'archive', '') + ).rejects.toThrow('No SHA-256') +}) + +it('rejects redirects outside the release hosts before making a request', async () => { + await expect( + downloads.download( + 'https://example.invalid/archive', + path.join(root, 'bad') + ) + ).rejects.toThrow('Invalid download URL') + await expect( + downloads.download('http://github.com/archive', path.join(root, 'bad')) + ).rejects.toThrow('Invalid download URL') +}) diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts deleted file mode 100644 index 793bafe..0000000 --- a/__tests__/main.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Unit tests for the action's main functionality, src/main.ts - * - * These should be run as if the action was called from a workflow. - * Specifically, the inputs listed in `action.yml` should be set as environment - * variables following the pattern `INPUT_`. - */ - -import * as core from '@actions/core' -import * as main from '../src/install' - -// Mock the GitHub Actions core library -// const debugMock = jest.spyOn(core, 'debug') -const getInputMock = jest.spyOn(core, 'getInput') -// const setFailedMock = jest.spyOn(core, 'setFailed') -// const setOutputMock = jest.spyOn(core, 'setOutput') - -// Mock the action's main function -const installGopMock = jest.spyOn(main, 'installLLGo') - -describe('action', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('sets gop version', async () => { - // Set the action's inputs as return values from core.getInput() - getInputMock.mockImplementation((name: string): string => { - switch (name) { - case 'gop-version': - return '1.1.7' - default: - return '' - } - }) - - await main.installLLGo() - - expect(installGopMock).toHaveReturned() - - // expect(setOutputMock).toHaveBeenCalledWith('gop-version', '1.1.7') - }) -}) diff --git a/__tests__/network.test.ts b/__tests__/network.test.ts new file mode 100644 index 0000000..2e58b91 --- /dev/null +++ b/__tests__/network.test.ts @@ -0,0 +1,102 @@ +import * as core from '@actions/core' +import { EventEmitter } from 'events' +import fs from 'fs' +import type { ClientRequest, IncomingMessage } from 'http' +import https from 'https' +import os from 'os' +import path from 'path' +import { Readable } from 'stream' +import { download } from '../src/download' +import { downloadTimeout, retryNetwork } from '../src/network' + +beforeEach(() => { + jest.useFakeTimers() + jest.spyOn(core, 'info').mockImplementation(() => {}) +}) +afterEach(() => { + jest.restoreAllMocks() + jest.useRealTimers() +}) + +it('retries wrapped fetch transport failures with bounded backoff', async () => { + const error = new TypeError('fetch failed', { + cause: Object.assign(new Error('reset'), { code: 'ECONNRESET' }) + }) + const operation = jest + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValue('ok') + const result = retryNetwork(operation) + await jest.advanceTimersByTimeAsync(999) + expect(operation).toHaveBeenCalledTimes(1) + await jest.advanceTimersByTimeAsync(1) + expect(await result).toBe('ok') + expect(operation).toHaveBeenCalledTimes(2) +}) + +it('stops after three failed transfers and uses 1s/2s delays', async () => { + const operation = jest.fn().mockRejectedValue(downloadTimeout()) + const result = (async () => { + try { + return await retryNetwork(operation) + } catch (error) { + return error + } + })() + await jest.advanceTimersByTimeAsync(1000) + expect(operation).toHaveBeenCalledTimes(2) + await jest.advanceTimersByTimeAsync(1999) + expect(operation).toHaveBeenCalledTimes(2) + await jest.advanceTimersByTimeAsync(1) + await expect(result).resolves.toMatchObject({ message: 'Download timed out' }) + expect(operation).toHaveBeenCalledTimes(3) +}) + +it.each([ + new Error('HTTP 404'), + new Error('HTTP 403'), + new Error('SHA-256 mismatch'), + Object.assign(new Error('disk full'), { code: 'ENOSPC' }) +])('does not retry permanent errors: %s', async error => { + const operation = jest.fn().mockRejectedValue(error) + await expect(retryNetwork(operation)).rejects.toBe(error) + expect(operation).toHaveBeenCalledTimes(1) +}) + +it('restarts an archive download after a connection reset', async () => { + // Use real stream scheduling; the one-second retry also exercises the actual + // wrapper used by downloads rather than only the retry helper. + jest.useRealTimers() + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llgo-download-test-')) + try { + const destination = path.join(root, 'archive') + fs.writeFileSync(destination, 'previous partial archive') + const get = jest.spyOn(https, 'get') + get.mockImplementation((_url, _options, callback) => { + const stream = + get.mock.calls.length === 1 + ? Readable.from( + (async function* () { + yield 'partial transfer' + throw Object.assign(new Error('reset'), { code: 'ECONNRESET' }) + })() + ) + : Readable.from(['complete archive']) + const response = Object.assign(stream, { + statusCode: 200, + headers: {}, + setTimeout: jest.fn() + }) as unknown as IncomingMessage + const request = Object.assign(new EventEmitter(), { + setTimeout: jest.fn() + }) as unknown as ClientRequest + process.nextTick(() => callback?.(response)) + return request + }) + await download('https://github.com/example/archive', destination) + expect(get).toHaveBeenCalledTimes(2) + expect(fs.readFileSync(destination, 'utf8')).toBe('complete archive') + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/__tests__/platform.test.ts b/__tests__/platform.test.ts new file mode 100644 index 0000000..4cd3ff0 --- /dev/null +++ b/__tests__/platform.test.ts @@ -0,0 +1,26 @@ +import { platformFor, releaseAsset } from '../src/platform' + +it.each([ + ['linux', 'amd64', '', 'linux-amd64.tar.gz'], + ['linux', 'arm64', '', 'linux-arm64.tar.gz'], + ['darwin', 'amd64', '', 'darwin-amd64.tar.gz'], + ['darwin', 'arm64', '', 'darwin-arm64.tar.gz'], + ['win32', 'amd64', 'msvc', 'windows-amd64-msvc.zip'], + ['win32', 'amd64', 'mingw', 'windows-amd64-mingw.zip'], + ['win32', 'arm64', 'msvc', 'windows-arm64-msvc.zip'], + ['win32', 'arm64', 'mingw', 'windows-arm64-mingw.zip'] +])('selects %s/%s/%s', (os, arch, abi, asset) => { + expect(releaseAsset('v1.0.4', platformFor(os, arch, abi))).toBe( + `llgo1.0.4.${asset}` + ) +}) +it.each(['x64', 'X64', 'x86_64'])('normalizes %s', arch => { + expect(platformFor('linux', arch, '').arch).toBe('amd64') +}) +it.each([ + ['freebsd', 'amd64', ''], + ['linux', '386', ''], + ['win32', 'amd64', 'invalid'] +])('rejects %s/%s/%s', (os, arch, abi) => { + expect(() => platformFor(os, arch, abi)).toThrow() +}) diff --git a/__tests__/resolve.test.ts b/__tests__/resolve.test.ts new file mode 100644 index 0000000..ce8d4c4 --- /dev/null +++ b/__tests__/resolve.test.ts @@ -0,0 +1,105 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' +import { + parseRemoteRefs, + resolveVersion, + RemoteRef, + versionInput, + verifyInstalledVersion +} from '../src/resolve' + +const refs: RemoteRef[] = [ + ...['v0.9.8', 'v1.0.3', 'v1.0.4', 'v1.1.0', 'v2.0.0-rc.1'].map((name, i) => ({ + name, + sha: String(i).repeat(40), + kind: 'tag' as const + })), + ...['main', 'release/1.0', 'feature/foo', 'feature/bar'].map(name => ({ + name, + sha: 'a'.repeat(40), + kind: 'branch' as const + })) +] +describe('LLGo ref resolution', () => { + it.each([ + ['1.0.4', 'v1.0.4'], + ['v1.0.4', 'v1.0.4'], + ['1.0', 'v1.0.4'], + ['v1.0', 'v1.0.4'], + ['v0.9', 'v0.9.8'], + ['1', 'v1.1.0'], + ['1.0.x', 'v1.0.4'], + ['v1.0.*', 'v1.0.4'], + ['v1.0.?', 'v1.0.4'], + ['^1.0.3', 'v1.1.0'], + ['>=1.0 <1.1', 'v1.0.4'], + ['', 'v1.1.0'], + ['latest', 'v1.1.0'], + ['*', 'v1.1.0'], + ['v2.0.0-rc.*', 'v2.0.0-rc.1'], + ['v2.0.0-rc.1', 'v2.0.0-rc.1'], + ['refs/tags/v1.0.4', 'v1.0.4'] + ])('resolves %s to %s', (spec, tag) => { + expect(resolveVersion(spec, refs).ref).toBe(`refs/tags/${tag}`) + }) + it.each(['main', 'release/1.0', 'release/*', 'refs/heads/release/1.0'])( + 'resolves branch %s', + spec => { + expect(resolveVersion(spec, refs).kind).toBe('branch') + } + ) + it.each(['abc1234', 'b'.repeat(40)])('accepts commit %s', sha => { + expect(resolveVersion(sha, refs)).toEqual({ kind: 'commit', ref: sha, sha }) + }) + it.each([ + 'missing', + '9.9', + 'feature/*', + 'refs/heads/missing', + '$(touch /tmp/no)', + '--help' + ])('rejects invalid or ambiguous %s', spec => { + expect(() => resolveVersion(spec, refs)).toThrow() + }) + it('peels annotated tags without fabricating a v prefix', () => { + const parsed = parseRemoteRefs( + `${'a'.repeat(40)}\trefs/tags/1.2.3\n${'b'.repeat( + 40 + )}\trefs/tags/1.2.3^{}\n${'c'.repeat(40)}\trefs/heads/main\n` + ) + expect(resolveVersion('1.2', parsed)).toEqual({ + ref: 'refs/tags/1.2.3', + sha: 'b'.repeat(40), + kind: 'tag' + }) + }) +}) + +it('reads version files and honors explicit version precedence', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llgo-version-file-')) + try { + const plain = path.join(dir, '.llgo-version') + fs.writeFileSync(plain, 'release/1.0\n') + expect(versionInput('', plain)).toBe('release/1.0') + const mod = path.join(dir, 'go.mod') + fs.writeFileSync(mod, 'module example\n// llgo v1.0.*\n') + expect(versionInput('', mod)).toBe('v1.0.*') + expect(versionInput('main', 'missing')).toBe('main') + expect(versionInput('', '')).toBe('') + fs.writeFileSync(mod, 'module example\n') + expect(() => versionInput('', mod)).toThrow('No // llgo') + fs.writeFileSync(plain, '') + expect(() => versionInput('', plain)).toThrow('Empty') + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) +it('validates the installed release patch version', () => { + expect(() => + verifyInstalledVersion('llgo v1.0.4 darwin/arm64', 'refs/tags/v1.0.4') + ).not.toThrow() + expect(() => + verifyInstalledVersion('llgo v1.0.3 darwin/arm64', 'refs/tags/v1.0.4') + ).toThrow('does not match') +}) diff --git a/action.yml b/action.yml index 8ea3773..8345eb9 100644 --- a/action.yml +++ b/action.yml @@ -1,117 +1,959 @@ -name: 'setup-llgo' -description: 'Setup a LLGo environment and add it to the PATH' -author: 'GoPlus Team' +name: setup-llgo +description: Install LLGo from a release, version range, branch or commit inputs: llgo-version: - description: - 'The LLGo version to download (if necessary) and use. Supports semver spec - and ranges. Be sure to enclose this option in single quotation marks.' - # go-version: - # description: - # 'The Go version to download (if necessary) and use. Supports semver spec - # and ranges. Be sure to enclose this option in single quotation marks.' - # go-version-file: - # description: 'Path to the go.mod or go.work file.' + description: Version, prefix, SemVer range, tag/branch glob (* or ?), branch, or commit SHA + default: '' + llgo-version-file: + description: Plain version/ref file, or go.mod/go.work containing a // llgo directive + default: '' + install-method: + description: auto prefers release archives and falls back to source; release or source forces that method + default: auto + go-version: + description: Go version used to build LLGo and compile user programs + default: '' + go-version-file: + description: Go toolchain version file; defaults to Go 1.27 when neither Go input is supplied + default: '' + llvm-version: + description: LLVM major version (Windows supports 22) + default: '22' + architecture: + description: Native host architecture (amd64/x64 or arm64); defaults to the runner architecture + default: '' + windows-abi: + description: Windows native ABI (msvc or mingw) + default: msvc + install-dependencies: + description: Install LLVM and native runtime dependencies + default: 'true' check-latest: - description: - 'Set this option to true if you want the action to always check for the - latest available version that satisfies the version spec' - default: false + description: Check for the latest Go toolchain matching go-version; LLGo refs are always resolved remotely + default: 'false' token: - description: - Used to pull Go distributions from go-versions. Since there's a default, - this is typically not supplied by the user. When running this action on - github.com, the default value is sufficient. When running on GHES, you can - pass a personal access token for github.com if you are experiencing rate - limiting. - default: - ${{ github.server_url == 'https://github.com' && github.token || '' }} + description: GitHub token for release metadata and Go downloads + default: ${{ github.token }} cache: - description: - Used to specify whether caching is needed. Set to true, if you'd like to - enable caching. - default: true + description: Cache Go modules and build outputs + default: 'true' cache-dependency-path: - description: 'Used to specify the path to a dependency file - go.sum' - architecture: - description: - 'Target architecture for Go to use. Examples: x86, x64. Will use system - architecture by default.' + description: Additional dependency files to include in the Go cache key + default: go.sum outputs: llgo-version: - description: - 'The installed LLGo version. Useful when given a version range as input.' + description: Installed compiler version + value: ${{ steps.install.outputs.llgo-version }} llgo-version-verified: - description: - Whether the installed LLGo version checked, true if the installed version - is in the tags, false otherwise. + description: Whether the selected ref is a tag + value: ${{ steps.prepare.outputs.llgo-version-verified }} + llgo-revision: + description: Resolved full commit SHA + value: ${{ steps.prepare.outputs.llgo-revision }} + llgo-ref: + description: Resolved tag, branch or requested commit + value: ${{ steps.prepare.outputs.llgo-ref }} + install-method: + description: Actual install method (release or source) + value: ${{ steps.prepare.outputs.install-method }} go-version: - description: - 'The installed Go version. Useful when given a version range as input.' + description: Installed Go version + value: ${{ steps.go.outputs.go-version }} cache-hit: - description: 'A boolean value to indicate if a cache was hit' + description: Whether the Go cache was restored + value: ${{ steps.go.outputs.cache-hit }} runs: - using: 'composite' + using: composite steps: - - name: Install dependencies - if: ${{ runner.os == 'macOS' }} + - name: Resolve and prepare LLGo + id: prepare shell: bash + run: node "$GITHUB_ACTION_PATH/dist/index.js" + env: + SETUP_LLGO_PHASE: prepare + INPUT_LLGO-VERSION: ${{ inputs.llgo-version }} + INPUT_LLGO-VERSION-FILE: ${{ inputs.llgo-version-file }} + INPUT_INSTALL-METHOD: ${{ inputs.install-method }} + INPUT_ARCHITECTURE: ${{ inputs.architecture }} + INPUT_WINDOWS-ABI: ${{ inputs.windows-abi }} + INPUT_TOKEN: ${{ inputs.token }} + - name: Select Go archive extractor on Windows + if: runner.os == 'Windows' && runner.arch == 'ARM64' + id: windows_archive_path + shell: powershell run: | - brew update - brew install llvm@18 bdw-gc openssl libffi - brew link --force libffi - echo "$(brew --prefix llvm@18)/bin" >> $GITHUB_PATH - - # Install optional deps for demos. - # - # NOTE: Keep this list updated as new deps are introduced. - opt_deps=( - cjson # for github.com/goplus/llgo/c/cjson - sqlite # for github.com/goplus/llgo/c/sqlite - python@3.12 # for github.com/goplus/llgo/py - ) - brew install "${opt_deps[@]}" + $ErrorActionPreference = "Stop" + $overrides = @{} + # Match setup-demo-deps' archive policy. PowerShell 7 can abort inside + # ZipFile.ExtractToDirectory before tool-cache's catch/fallback runs. + # Hide it only from setup-go. Use the runner architecture above: the + # installed Go may be x64. If pwsh is absent, tool-cache already uses + # Windows PowerShell, so no PATH override is needed. + $pwsh = Get-Command pwsh.exe -ErrorAction SilentlyContinue + if ($pwsh) { + $pwshDir = (Split-Path -Parent $pwsh.Source).TrimEnd('\') + $path = (($env:PATH -split ';') | Where-Object { + $_ -and $_.Trim().Trim('"').TrimEnd('\') -ine $pwshDir + }) -join ';' + if ($path -eq $env:PATH) { + throw "Failed to remove PowerShell 7 from setup-go's PATH" + } + $overrides.PATH = $path + } + $json = ConvertTo-Json -InputObject $overrides -Compress + "env=$json" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - - name: Install dependencies - if: ${{ runner.os == 'Linux' }} - shell: bash + - name: Set up Go + id: go + uses: actions/setup-go@v7 + env: ${{ fromJSON(steps.windows_archive_path.outputs.env || '{}') }} + with: + go-version: ${{ inputs.go-version || (inputs.go-version-file == '' && '1.27' || '') }} + go-version-file: ${{ inputs.go-version-file }} + architecture: ${{ steps.prepare.outputs.architecture == 'amd64' && 'x64' || 'arm64' }} + check-latest: ${{ inputs.check-latest }} + token: ${{ inputs.token }} + cache: ${{ inputs.cache }} + cache-dependency-path: | + ${{ inputs.cache-dependency-path }} + ${{ steps.prepare.outputs.install-dir }}/go.sum + ${{ steps.prepare.outputs.install-dir }}/runtime/go.sum + ${{ steps.prepare.outputs.install-dir }}/.git/HEAD + # Windows dependency setup is adapted from xgo-dev/llgo at 7db1409073f28bf13a35ae0a3ea663eba010fd73. + - name: Validate Windows toolchain profile + if: runner.os == 'Windows' + shell: pwsh run: | - echo "deb http://apt.llvm.org/$(lsb_release -cs)/ llvm-toolchain-$(lsb_release -cs)-18 main" | sudo tee /etc/apt/sources.list.d/llvm.list - wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add - - sudo apt-get update - sudo apt-get install -y llvm-18-dev clang-18 libclang-18-dev lld-18 pkg-config libgc-dev libssl-dev zlib1g-dev libffi-dev libcjson-dev - echo "/usr/lib/llvm-18/bin" >> $GITHUB_PATH - - # Install optional deps for demos. - # - # NOTE: Keep this list updated as new deps are introduced. - opt_deps=( - libcjson-dev # for github.com/goplus/llgo/c/cjson - libsqlite3-dev # for github.com/goplus/llgo/c/sqlite - python3.12-dev # for github.com/goplus/llgo/py + if ("${{ inputs.windows-abi }}" -notin @("msvc", "mingw")) { + throw "Unsupported Windows ABI profile: ${{ inputs.windows-abi }}" + } + if ("${{ steps.prepare.outputs.architecture }}" -notin @("386", "amd64", "arm64")) { + throw "Unsupported Windows target architecture: ${{ steps.prepare.outputs.architecture }}" + } + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_WINDOWS_ABI=${{ inputs.windows-abi }}" + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_WINDOWS_ARCH=${{ steps.prepare.outputs.architecture }}" + + - name: Cache compressed full-target Windows LLVM archive + if: runner.os == 'Windows' && inputs.install-dependencies == 'true' && inputs.windows-abi == 'msvc' + uses: actions/cache@v6 + with: + # Cache the host architecture's full LLVM archive: x64 on windows-2022 + # and native ARM64 on windows-11-arm. + path: ${{ runner.tool_cache }}\llgo-llvm-archives\${{ runner.arch == 'ARM64' && 'clang+llvm-22.1.8-aarch64-pc-windows-msvc.tar.xz' || 'clang+llvm-22.1.8-x86_64-pc-windows-msvc.tar.xz' }} + key: ${{ runner.arch == 'ARM64' && 'llgo-llvm-archive-22.1.8-aarch64-pc-windows-msvc-de718c58ebbc5f61d58c17b90457fcf42983bc2c4a4aba3e010d108713bfd7f1' || 'llgo-llvm-archive-22.1.8-x86_64-pc-windows-msvc-d96c2cc1736f4eb7fa43cb9bbdf56d93551a9ae0a9aadb9c99c3c3b2b712a234' }} + + - name: Install full-target Windows LLVM + if: runner.os == 'Windows' && inputs.install-dependencies == 'true' && inputs.windows-abi == 'msvc' + shell: pwsh + env: + # The development archive contains llvm-config, headers, libraries, + # Clang, LLD, and the WebAssembly backend required by LLGo's Windows + # host and WASI cross-compilation paths. + LLVM_VERSION: "22.1.8" + run: | + $ErrorActionPreference = "Stop" + if ("${{ inputs.llvm-version }}" -ne "22") { + throw "Unsupported Windows LLVM version: ${{ inputs.llvm-version }}" + } + + $hostArch = if ($env:RUNNER_ARCH -eq "ARM64") { "aarch64" } else { "x86_64" } + $expectedSha256 = if ($env:RUNNER_ARCH -eq "ARM64") { + "de718c58ebbc5f61d58c17b90457fcf42983bc2c4a4aba3e010d108713bfd7f1" + } else { + "d96c2cc1736f4eb7fa43cb9bbdf56d93551a9ae0a9aadb9c99c3c3b2b712a234" + } + + $archiveName = "clang+llvm-$env:LLVM_VERSION-$hostArch-pc-windows-msvc.tar.xz" + $archiveParent = Join-Path $env:RUNNER_TOOL_CACHE "llgo-llvm-archives" + $archive = Join-Path $archiveParent $archiveName + New-Item -ItemType Directory -Force $archiveParent | Out-Null + if (-not (Test-Path $archive)) { + $urlAsset = $archiveName.Replace("+", "%2B") + $url = "https://github.com/llvm/llvm-project/releases/download/llvmorg-$env:LLVM_VERSION/$urlAsset" + & curl.exe --fail --location --retry 5 --retry-all-errors --output $archive $url + if ($LASTEXITCODE -ne 0) { + throw "Downloading LLVM failed with exit code $LASTEXITCODE" + } + } + $actual = (Get-FileHash -Algorithm SHA256 $archive).Hash + if (-not $actual.Equals($expectedSha256, [StringComparison]::OrdinalIgnoreCase)) { + throw "LLVM archive SHA-256 is $actual, want $expectedSha256" + } + + $extractParent = Join-Path $env:RUNNER_TEMP "llgo-llvm-extract" + $installParent = Join-Path $env:RUNNER_TOOL_CACHE "llgo-llvm" + $installRoot = Join-Path $installParent "clang+llvm-$env:LLVM_VERSION-$hostArch-pc-windows-msvc" + $sevenZip = Join-Path $env:ProgramFiles "7-Zip\7z.exe" + if (-not (Test-Path $sevenZip)) { + throw "7-Zip was not found at $sevenZip" + } + New-Item -ItemType Directory -Force $extractParent, $installParent | Out-Null + & $sevenZip x -y "-o$extractParent" $archive + if ($LASTEXITCODE -ne 0) { + throw "Decompressing the LLVM xz archive failed with exit code $LASTEXITCODE" + } + $tar = Get-ChildItem -LiteralPath $extractParent -Filter "*.tar" | Select-Object -First 1 + if (-not $tar) { + throw "The LLVM xz archive did not contain a tar stream" + } + & $sevenZip x -y "-o$extractParent" $tar.FullName + if ($LASTEXITCODE -ne 0) { + throw "Extracting the LLVM tar archive failed with exit code $LASTEXITCODE" + } + $extractedRoot = Get-ChildItem -LiteralPath $extractParent -Directory | + Where-Object { Test-Path (Join-Path $_.FullName "bin\llvm-config.exe") } | + Select-Object -First 1 + if (-not $extractedRoot) { + throw "llvm-config.exe was not found in the full-target LLVM archive" + } + Move-Item -LiteralPath $extractedRoot.FullName -Destination $installRoot + Remove-Item -LiteralPath $tar.FullName + + - name: Cache Windows LLDB and target builtins + if: runner.os == 'Windows' && inputs.install-dependencies == 'true' && (inputs.windows-abi == 'msvc' || steps.prepare.outputs.architecture != 'amd64') + id: windows_target_llvm_cache + uses: actions/cache@v6 + with: + path: ${{ runner.tool_cache }}\llgo-windows-support\22.1.8\${{ steps.prepare.outputs.architecture }} + key: llgo-windows-support-22.1.8-${{ steps.prepare.outputs.architecture }}-${{ inputs.windows-abi }}-16e5709785fef73c854646241c4a92c5cd574318d1b33c63330dd7721903e55c-76f44ef1ba6eeb5a65904e9500f042f588fade49952778ce48f0374daa934396-c68b26561883d5c7f33307aeb2b9b6c188eb705808b3f780dfce0be49f4e835d-native-lldb-v2 + + - name: Install Windows LLDB and target builtins + if: runner.os == 'Windows' && inputs.install-dependencies == 'true' && steps.prepare.outputs.architecture != '386' && (inputs.windows-abi == 'msvc' || steps.prepare.outputs.architecture != 'amd64') && steps.windows_target_llvm_cache.outputs.cache-hit != 'true' + shell: pwsh + env: + LLVM_VERSION: "22.1.8" + LLVM_INSTALLER_ARCH: ${{ steps.prepare.outputs.architecture == 'arm64' && 'woa64' || 'win64' }} + LLVM_INSTALLER_SHA256: ${{ steps.prepare.outputs.architecture == 'arm64' && '76f44ef1ba6eeb5a65904e9500f042f588fade49952778ce48f0374daa934396' || '16e5709785fef73c854646241c4a92c5cd574318d1b33c63330dd7721903e55c' }} + LLVM_BUILTINS_ARCH: ${{ steps.prepare.outputs.architecture == 'arm64' && 'aarch64' || steps.prepare.outputs.architecture == 'amd64' && 'x86_64' || 'i386' }} + run: | + $ErrorActionPreference = "Stop" + + # The upstream x64 full-target archive supplies the host LLVM tools but + # omits LLDB and only carries x64 compiler-rt. Official LLVM installers + # supply x64/ARM64 LLDB and builtins. The separate Win32 step below + # uses llvm-mingw's native i686 LLDB and bundled Python. + $installerName = "LLVM-$env:LLVM_VERSION-$env:LLVM_INSTALLER_ARCH.exe" + $installer = Join-Path $env:RUNNER_TEMP $installerName + $url = "https://github.com/llvm/llvm-project/releases/download/llvmorg-$env:LLVM_VERSION/$installerName" + & curl.exe --fail --location --retry 5 --retry-all-errors --output $installer $url + if ($LASTEXITCODE -ne 0) { + throw "Downloading the LLVM ${{ steps.prepare.outputs.architecture }} installer failed with exit code $LASTEXITCODE" + } + $actual = (Get-FileHash -Algorithm SHA256 $installer).Hash + if (-not $actual.Equals($env:LLVM_INSTALLER_SHA256, [StringComparison]::OrdinalIgnoreCase)) { + throw "LLVM ${{ steps.prepare.outputs.architecture }} installer SHA-256 is $actual, want $env:LLVM_INSTALLER_SHA256" + } + + $extractRoot = Join-Path $env:RUNNER_TEMP "llgo-llvm-${{ steps.prepare.outputs.architecture }}-extract" + $sevenZip = Join-Path $env:ProgramFiles "7-Zip\7z.exe" + if (-not (Test-Path $sevenZip)) { + throw "7-Zip was not found at $sevenZip" + } + $builtinsRelative = "lib\clang\22\lib\windows\clang_rt.builtins-$env:LLVM_BUILTINS_ARCH.lib" + $extractPaths = @( + "bin\lldb.exe", + "bin\liblldb.dll", + "bin\lldb-argdumper.exe", + "bin\lldb-server.exe", + "lib\site-packages\lldb\*" ) - sudo apt-get install -y "${opt_deps[@]}" + $extractPaths += $builtinsRelative + & $sevenZip x -y "-o$extractRoot" $installer @extractPaths + if ($LASTEXITCODE -ne 0) { + throw "Extracting LLVM ${{ steps.prepare.outputs.architecture }} support failed with exit code $LASTEXITCODE" + } + $cacheRoot = Join-Path $env:RUNNER_TOOL_CACHE "llgo-windows-support\$env:LLVM_VERSION\${{ steps.prepare.outputs.architecture }}" + New-Item -ItemType Directory -Force $cacheRoot | Out-Null + if ("${{ steps.prepare.outputs.architecture }}" -ne "amd64" -and "${{ inputs.windows-abi }}" -eq "msvc") { + $source = Join-Path $extractRoot $builtinsRelative + if (-not $source -or -not (Test-Path $source)) { + throw "The LLVM ${{ steps.prepare.outputs.architecture }} compiler-rt builtins were not found" + } + Copy-Item -LiteralPath $source -Destination (Join-Path $cacheRoot "clang_rt.builtins-$env:LLVM_BUILTINS_ARCH.lib") + } + $lldbRoot = Join-Path $cacheRoot "lldb" + New-Item -ItemType Directory -Force $lldbRoot | Out-Null + Copy-Item -LiteralPath (Join-Path $extractRoot "bin") -Destination $lldbRoot -Recurse + Copy-Item -LiteralPath (Join-Path $extractRoot "lib") -Destination $lldbRoot -Recurse + foreach ($required in @( + (Join-Path $lldbRoot "bin\lldb.exe"), + (Join-Path $lldbRoot "bin\liblldb.dll"), + (Join-Path $lldbRoot "lib\site-packages\lldb\__init__.py") + )) { + if (-not (Test-Path $required)) { + throw "The native Windows ${{ steps.prepare.outputs.architecture }} LLDB installation is incomplete: $required" + } + } + + - name: Cache Windows MSVC dependencies + if: runner.os == 'Windows' && inputs.install-dependencies == 'true' && inputs.windows-abi == 'msvc' + id: windows_vcpkg_cache + uses: actions/cache@v6 + with: + path: ${{ runner.tool_cache }}\llgo-vcpkg\${{ '1ceca7923abb0aae08d92741596edd67158219b49630552e545fe627dd00f8dd' }} + key: llgo-vcpkg-${{ steps.prepare.outputs.architecture }}-windows-${{ runner.arch }}-${{ '1ceca7923abb0aae08d92741596edd67158219b49630552e545fe627dd00f8dd' }} + + - name: Install Windows MSVC dependencies + if: runner.os == 'Windows' && inputs.install-dependencies == 'true' && inputs.windows-abi == 'msvc' && steps.windows_vcpkg_cache.outputs.cache-hit != 'true' + shell: pwsh + env: + VCPKG_BASELINE: ddd0023b0eee70986e42ed49d9d4afb8098f212e + VCPKG_MANIFEST_HASH: ${{ '1ceca7923abb0aae08d92741596edd67158219b49630552e545fe627dd00f8dd' }} + run: | + $ErrorActionPreference = "Stop" + $sourceRoot = Join-Path $env:RUNNER_TEMP "llgo-vcpkg-source" + $installRoot = Join-Path $env:RUNNER_TOOL_CACHE "llgo-vcpkg\$env:VCPKG_MANIFEST_HASH" + $manifestRoot = Join-Path $env:SETUP_LLGO_ACTION_PATH "scripts\windows\vcpkg" + . (Join-Path $env:SETUP_LLGO_ACTION_PATH "scripts\windows\msvc-target.ps1") + $target = Get-LLGoWindowsMSVCTarget -GoArch "${{ steps.prepare.outputs.architecture }}" + + # Version overrides refer to historical Git tree objects. Keep the + # complete Git object database rather than a source ZIP or partial + # clone so vcpkg does not need a second network request while it + # materializes the pinned libffi port. + & git clone --no-checkout https://github.com/microsoft/vcpkg.git $sourceRoot + if ($LASTEXITCODE -ne 0) { + throw "Cloning vcpkg failed with exit code $LASTEXITCODE" + } + & git -C $sourceRoot checkout --detach $env:VCPKG_BASELINE + if ($LASTEXITCODE -ne 0) { + throw "Checking out vcpkg $env:VCPKG_BASELINE failed with exit code $LASTEXITCODE" + } + & (Join-Path $sourceRoot "bootstrap-vcpkg.bat") -disableMetrics + if ($LASTEXITCODE -ne 0) { + throw "Bootstrapping vcpkg failed with exit code $LASTEXITCODE" + } + $hostVcpkgTriplet = if ($env:RUNNER_ARCH -eq "ARM64") { "arm64-windows" } else { "x64-windows" } + & (Join-Path $sourceRoot "vcpkg.exe") install ` + "--triplet=$($target.VcpkgTriplet)" ` + "--host-triplet=$hostVcpkgTriplet" ` + "--x-manifest-root=$manifestRoot" ` + "--x-install-root=$installRoot" ` + --clean-after-build + if ($LASTEXITCODE -ne 0) { + throw "Installing vcpkg dependencies failed with exit code $LASTEXITCODE" + } - - name: Install denied on Windows - if: ${{ runner.os == 'Windows' }} + - name: Configure standalone Windows LLVM, SDK, and dependencies + if: runner.os == 'Windows' && inputs.install-dependencies == 'true' && inputs.windows-abi == 'msvc' shell: pwsh + env: + LLVM_VERSION: "22.1.8" + LLVM_TARGET_VERSION: "22.1.8" + VCPKG_MANIFEST_HASH: ${{ '1ceca7923abb0aae08d92741596edd67158219b49630552e545fe627dd00f8dd' }} run: | - Write-Error "Install denied on Windows, currently not supported" + $ErrorActionPreference = "Stop" + + $hostArch = if ($env:RUNNER_ARCH -eq "ARM64") { "aarch64" } else { "x86_64" } + $hostTriple = if ($env:RUNNER_ARCH -eq "ARM64") { "aarch64-pc-windows-msvc" } else { "x86_64-pc-windows-msvc" } + $hostGoArch = if ($env:RUNNER_ARCH -eq "ARM64") { "arm64" } else { "amd64" } + $hostVcpkgTriplet = if ($env:RUNNER_ARCH -eq "ARM64") { "arm64-windows" } else { "x64-windows" } + + $llvmRoot = Join-Path $env:RUNNER_TOOL_CACHE "llgo-llvm\clang+llvm-$env:LLVM_VERSION-$hostArch-pc-windows-msvc" + if (-not (Test-Path (Join-Path $llvmRoot "bin\llvm-config.exe"))) { + throw "The full-target LLVM installation is incomplete: $llvmRoot" + } + $llvmBin = Join-Path $llvmRoot "bin" + $llvmConfig = Join-Path $llvmBin "llvm-config.exe" + $vcpkgRoot = Join-Path $env:RUNNER_TOOL_CACHE "llgo-vcpkg\$env:VCPKG_MANIFEST_HASH" + . (Join-Path $env:SETUP_LLGO_ACTION_PATH "scripts\windows\msvc-target.ps1") + $target = Get-LLGoWindowsMSVCTarget -GoArch "${{ steps.prepare.outputs.architecture }}" + $vcpkgTripletRoot = Join-Path $vcpkgRoot $target.VcpkgTriplet + $vcpkgHostRoot = Join-Path $vcpkgRoot $hostVcpkgTriplet + if (-not (Test-Path (Join-Path $vcpkgHostRoot "tools\pkgconf\pkgconf.exe")) -and (Test-Path (Join-Path $vcpkgRoot "x64-windows\tools\pkgconf\pkgconf.exe"))) { + $vcpkgHostRoot = Join-Path $vcpkgRoot "x64-windows" + } + $pkgconf = Join-Path $vcpkgHostRoot "tools\pkgconf\pkgconf.exe" + if (-not (Test-Path (Join-Path $vcpkgTripletRoot "include\gc\gc.h"))) { + throw "The cached $($target.VcpkgTriplet) dependency installation is incomplete" + } + if (-not (Test-Path $pkgconf)) { + throw "The native pkgconf executable was not installed at $pkgconf" + } + + # LLGo itself and the LLVM Go bindings are host programs even when + # this job will later execute ARM64 or 386 output. Target SDK variables + # are activated only after the host compiler has been installed. + $null = Enter-LLGoVisualStudio -GoArch $hostGoArch + + # Clang emits the MSVC ABI but uses the SDK/UCRT selected by the + # Visual Studio developer environment. Persist the host search + # variables and tools used to build the LLGo compiler itself. + Export-LLGoVisualStudioEnvironment + # Enter-VsDevShell only updates this action process. Export the native + # SDK tools needed by later composite actions without persisting its + # complete PATH (which can include unrelated runner-image toolchains). + $pcDir = Join-Path $env:RUNNER_TEMP "llgo-pkgconfig" + New-Item -ItemType Directory -Force $pcDir | Out-Null + $cflags = ((& $llvmConfig --cflags) -join " ").Trim().Replace('\', '/') + if ($LASTEXITCODE -ne 0) { + throw "llvm-config failed with exit code $LASTEXITCODE" + } + $llvmLibDir = ((& $llvmConfig --libdir) -join " ").Trim().Replace('\', '/') + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $llvmLibDir)) { + throw "llvm-config reported an invalid library directory: $llvmLibDir" + } + $llvmLibOutput = ((& $llvmConfig --link-static --libnames all) -join " ").Trim() + if ($LASTEXITCODE -ne 0) { + throw "llvm-config failed to report the static LLVM libraries" + } + $llvmLibNames = ($llvmLibOutput -split '\s+') | Where-Object { $_ } + if ($llvmLibNames.Count -eq 0) { + throw "llvm-config did not report the static LLVM libraries" + } + $systemLibOutput = ((& $llvmConfig --link-static --system-libs) -join " ").Trim() + if ($LASTEXITCODE -ne 0) { + throw "llvm-config failed to report the Windows system libraries" + } + $systemLibNames = ($systemLibOutput -split '\s+') | + Where-Object { $_ -and ($_ -notin @("libxml2s.lib", "xml2s.lib")) } + # The official archives have reported both libxml2s and xml2s for + # optional XML-backed Windows-manifest support without shipping that + # library. The LLVM Go bindings do not use that component; retaining + # it would make every host compiler depend on an unrelated third-party + # archive. + $libraryFlags = (@($llvmLibNames) + @($systemLibNames)) | ForEach-Object { + if (-not $_.EndsWith(".lib", [StringComparison]::OrdinalIgnoreCase)) { + throw "llvm-config reported an unsupported Windows library argument: $_" + } + "-l$([IO.Path]::GetFileNameWithoutExtension($_))" + } + # llvm-config prints full .lib paths on Windows. Express the same + # ordered static link as standard -L/-l flags so pkgconf does not treat + # backslashes as escapes and Go's CGO flag validation remains intact. + $ldflags = (@("-L$llvmLibDir") + @($libraryFlags)) -join " " + @" + Name: LLVM ${{ inputs.llvm-version }} + Description: LLVM ${{ inputs.llvm-version }} host compiler and linker flags + Version: $env:LLVM_VERSION + Cflags: $cflags + Libs: $ldflags + "@ | Set-Content -Encoding ascii (Join-Path $pcDir "llvm-${{ inputs.llvm-version }}.pc") + + # Upstream Windows LLVM release archives contain LLVMFileCheck.lib but + # omit the FileCheck executable used by LLGo's IR regression tests. + # Build that utility from its matching, hash-pinned upstream source so + # the native CI lane does not depend on an MSYS2 LLVM tools package. + $fileCheckRoot = Join-Path $env:RUNNER_TEMP "llgo-filecheck" + $fileCheckSource = Join-Path $fileCheckRoot "FileCheck.cpp" + $fileCheck = Join-Path $fileCheckRoot "FileCheck.exe" + $fileCheckSourceSHA256 = "394d3744e5b88e72e08693ae35510a2cea8a7bae209dbd31e08c2d288ef15b17" + New-Item -ItemType Directory -Force $fileCheckRoot | Out-Null + $fileCheckURL = "https://raw.githubusercontent.com/llvm/llvm-project/llvmorg-$env:LLVM_VERSION/llvm/utils/FileCheck/FileCheck.cpp" + & curl.exe --fail --location --retry 5 --retry-all-errors --output $fileCheckSource $fileCheckURL + if ($LASTEXITCODE -ne 0) { + throw "Downloading FileCheck source failed with exit code $LASTEXITCODE" + } + $fileCheckSourceActual = (Get-FileHash -Algorithm SHA256 $fileCheckSource).Hash + if (-not $fileCheckSourceActual.Equals($fileCheckSourceSHA256, [StringComparison]::OrdinalIgnoreCase)) { + throw "FileCheck source SHA-256 is $fileCheckSourceActual, want $fileCheckSourceSHA256" + } + $fileCheckLibraries = @("LLVMFileCheck.lib", "LLVMSupport.lib", "LLVMDemangle.lib") | + ForEach-Object { Join-Path $llvmLibDir $_ } + $fileCheckSystemLibraries = $systemLibNames | ForEach-Object { + "-l$([IO.Path]::GetFileNameWithoutExtension($_))" + } + foreach ($library in $fileCheckLibraries) { + if (-not (Test-Path $library)) { + throw "FileCheck dependency was not found: $library" + } + } + $llvmIncludeDir = Join-Path $llvmRoot "include" + & (Join-Path $llvmBin "clang++.exe") ` + --target=$hostTriple ` + -fuse-ld=lld ` + -fms-runtime-lib=static ` + -std=c++17 ` + "-I$llvmIncludeDir" ` + $fileCheckSource ` + $fileCheckLibraries ` + $fileCheckSystemLibraries ` + -o $fileCheck + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $fileCheck)) { + throw "Building the native FileCheck utility failed with exit code $LASTEXITCODE" + } + $fileCheckInput = Join-Path $fileCheckRoot "smoke.txt" + "CHECK: llgo-filecheck" | Set-Content -Encoding ascii $fileCheckInput + "llgo-filecheck" | & $fileCheck $fileCheckInput + if ($LASTEXITCODE -ne 0) { + throw "The native FileCheck smoke test failed with exit code $LASTEXITCODE" + } + + # Native pkgconf treats a variable literally named `prefix` as a + # relocatable installation marker by default. vcpkg's .pc files are + # already relocatable. Put a self-contained conventional pkg-config + # command on PATH so callers need neither PKG_CONFIG nor + # PKG_CONFIG_PATH, and so the MSVC profile cannot see MinGW metadata. + $nativeTools = Join-Path $env:RUNNER_TEMP "llgo-msvc-tools" + New-Item -ItemType Directory -Force $nativeTools | Out-Null + $pkgconfWrapper = Join-Path $nativeTools "pkg-config.cmd" + $vcpkgLibPC = Join-Path $vcpkgTripletRoot "lib\pkgconfig" + $vcpkgSharePC = Join-Path $vcpkgTripletRoot "share\pkgconfig" + @" + @echo off + "$pkgconf" --dont-define-prefix "--with-path=$pcDir" "--with-path=$vcpkgLibPC" "--with-path=$vcpkgSharePC" %* + "@ | Set-Content -Encoding ascii $pkgconfWrapper + $pkgconfShell = Join-Path $nativeTools "pkg-config" + $pkgconfUnix = $pkgconf.Replace('\', '/') + $pcDirUnix = $pcDir.Replace('\', '/') + $vcpkgLibPCUnix = $vcpkgLibPC.Replace('\', '/') + $vcpkgSharePCUnix = $vcpkgSharePC.Replace('\', '/') + @" + #!/usr/bin/env bash + set -euo pipefail + MSYS2_ARG_CONV_EXCL='*' exec "$pkgconfUnix" --dont-define-prefix "--with-path=$pcDirUnix" "--with-path=$vcpkgLibPCUnix" "--with-path=$vcpkgSharePCUnix" "`$@" + "@ | Set-Content -Encoding ascii $pkgconfShell + + $gcLibFlags = ((& $pkgconfWrapper --libs bdw-gc) -join " ").Trim() -split '\s+' + if ($LASTEXITCODE -ne 0) { + throw "pkgconf failed to resolve bdw-gc libraries" + } + $gcLibDirFlag = $gcLibFlags | Where-Object { $_.StartsWith("-L") } | Select-Object -First 1 + if (-not $gcLibDirFlag) { + throw "pkgconf did not report the bdw-gc library directory: $gcLibFlags" + } + $gcLibDir = [IO.Path]::GetFullPath($gcLibDirFlag.Substring(2)) + $expectedVcpkgLibDir = [IO.Path]::GetFullPath((Join-Path $vcpkgTripletRoot "lib")) + if (-not $gcLibDir.Equals($expectedVcpkgLibDir, [StringComparison]::OrdinalIgnoreCase)) { + throw "pkgconf resolved bdw-gc to $gcLibDir, want $expectedVcpkgLibDir" + } + # Go's Windows external linker detects LLD through the compiler command + # prefix. Without this option it emits a GNU ld linker script that the + # native MSVC linker dialect cannot consume. + # The upstream LLVM 22 development archive builds its static libraries + # against the static MSVC runtime. Match it in FileCheck and all host + # cgo objects so lld-link sees one RuntimeLibrary directive. + Add-Content -Encoding utf8 $env:GITHUB_ENV "CC=clang --target=$hostTriple -fuse-ld=lld -fms-runtime-lib=static" + Add-Content -Encoding utf8 $env:GITHUB_ENV "CXX=clang++ --target=$hostTriple -fuse-ld=lld -fms-runtime-lib=static -std=c++17" + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_WINDOWS_HOST_CC=clang --target=$hostTriple -fuse-ld=lld -fms-runtime-lib=static" + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_WINDOWS_HOST_CXX=clang++ --target=$hostTriple -fuse-ld=lld -fms-runtime-lib=static -std=c++17" + Add-Content -Encoding utf8 $env:GITHUB_ENV "CGO_ENABLED=1" + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_WINDOWS_TARGET_TRIPLE=$($target.Triple)" + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_WINDOWS_VCPKG_TRIPLET=$($target.VcpkgTriplet)" + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_WINDOWS_VCPKG_ROOT=$vcpkgTripletRoot" + Add-Content -Encoding utf8 $env:GITHUB_PATH $fileCheckRoot + Add-Content -Encoding utf8 $env:GITHUB_PATH $llvmBin + if ($target.GoArch -eq "amd64") { + Add-Content -Encoding utf8 $env:GITHUB_PATH (Join-Path $vcpkgTripletRoot "bin") + } + # GITHUB_PATH is prepended in reverse write order. Add the wrapper + # last so plain `pkg-config` always selects the profile-local command. + Add-Content -Encoding utf8 $env:GITHUB_PATH $nativeTools + + $lldbRoot = Join-Path $env:RUNNER_TOOL_CACHE "llgo-windows-support\$env:LLVM_TARGET_VERSION\$($target.GoArch)\lldb" + $lldb = Join-Path $lldbRoot "bin\lldb.exe" + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_WINDOWS_LLDB_PYTHONPATH=$(Join-Path $lldbRoot 'lib\site-packages')" + if (-not (Test-Path $lldb)) { + throw "lldb.exe was not found for Windows $($target.GoArch)" + } + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_LLDB=$lldb" + + $version = (& $llvmConfig --version).Trim() + if ($version -ne $env:LLVM_VERSION) { + throw "Expected LLVM $env:LLVM_VERSION, got $version" + } + $targetsBuilt = @((& $llvmConfig --targets-built) -split '\s+' | Where-Object { $_ }) + if ($targetsBuilt -notcontains "WebAssembly") { + throw "LLVM lacks the WebAssembly backend required by Windows-host cross-compilation: $targetsBuilt" + } + $defaultTarget = (& (Join-Path $llvmBin "clang.exe") -dumpmachine).Trim() + if ($defaultTarget -ne $hostTriple) { + throw "Standalone Clang defaults to $defaultTarget, want $hostTriple" + } + $builtins = (& (Join-Path $llvmBin "clang.exe") --target=$hostTriple --rtlib=compiler-rt -print-libgcc-file-name).Trim() + if (-not (Test-Path $builtins)) { + throw "Clang's MSVC compiler-rt builtins were not found at $builtins" + } + $targetBuiltins = (& (Join-Path $llvmBin "clang.exe") "--target=$($target.Triple)" --rtlib=compiler-rt -print-libgcc-file-name).Trim() + if ($target.Triple -ne $hostTriple -and -not (Test-Path $targetBuiltins)) { + $builtinsArch = if ($target.GoArch -eq "arm64") { "aarch64" } else { "i386" } + $cachedBuiltins = Join-Path $env:RUNNER_TOOL_CACHE "llgo-windows-support\$env:LLVM_TARGET_VERSION\$($target.GoArch)\clang_rt.builtins-$builtinsArch.lib" + if (-not (Test-Path $cachedBuiltins)) { + throw "The cached Windows $($target.GoArch) compiler-rt builtins were not found at $cachedBuiltins" + } + # Let the host Clang driver discover the target library through + # its normal resource-dir lookup, exactly as it does for x86/x64. + New-Item -ItemType Directory -Force (Split-Path $targetBuiltins) | Out-Null + Copy-Item -LiteralPath $cachedBuiltins -Destination $targetBuiltins + } + if (-not (Test-Path $targetBuiltins)) { + throw "Clang's $($target.Triple) compiler-rt builtins were not found at $targetBuiltins" + } + foreach ($package in @("llvm-${{ inputs.llvm-version }}", "bdw-gc", "libffi", "libuv", "openssl", "libcjson", "sqlite3", "zlib")) { + & $pkgconfWrapper --modversion $package + if ($LASTEXITCODE -ne 0) { + throw "pkg-config metadata for $package is unavailable" + } + } - - name: 'Setup Go' - uses: 'actions/setup-go@v5' + - name: Set up Windows MinGW + if: runner.os == 'Windows' && inputs.install-dependencies == 'true' && inputs.windows-abi == 'mingw' + id: windows_mingw + uses: msys2/setup-msys2@v2 with: - go-version: "1.20" # ${{ inputs.go-version }} - go-version-file: ${{ inputs.go-version-file }} - check-latest: ${{ inputs.check-latest }} - token: ${{ inputs.token }} - cache: ${{ inputs.cache }} - cache-dependency-path: ${{ inputs.cache-dependency-path }} - architecture: ${{ inputs.architecture }} + # CLANGARM64 supplies an architecture-native LLVM host and target on + # the ARM64 runner. The x64 runner keeps CLANG64 as the LLGo host even + # when R11 activates a separate llvm-mingw 386 target afterwards. + msystem: ${{ steps.prepare.outputs.architecture == 'arm64' && 'CLANGARM64' || 'CLANG64' }} + path-type: inherit + update: true + + - name: Test MSYS2 package retry helper + if: runner.os == 'Windows' && inputs.install-dependencies == 'true' && inputs.windows-abi == 'mingw' + shell: msys2 {0} + run: bash "$GITHUB_ACTION_PATH/scripts/pacman_retry_test.sh" + + - name: Install Windows MinGW dependencies + if: runner.os == 'Windows' && inputs.install-dependencies == 'true' && inputs.windows-abi == 'mingw' + shell: msys2 {0} + run: | + set -euo pipefail + source "$GITHUB_ACTION_PATH/scripts/pacman_retry.sh" + + llvm_major="${{ inputs.llvm-version }}" + case "$llvm_major" in + 22) + llvm_version=22.1.8 + package_version=22.1.8-2 + runtime_package_version=22.1.8-1 + ;; + *) + echo "unsupported Windows LLVM version: $llvm_major" >&2 + exit 1 + ;; + esac + + case "${{ steps.prepare.outputs.architecture }}" in + arm64) + repo=https://repo.msys2.org/mingw/clangarm64 + prefix=mingw-w64-clang-aarch64 + ;; + amd64|386) + repo=https://repo.msys2.org/mingw/clang64 + prefix=mingw-w64-clang-x86_64 + ;; + *) + echo "unsupported Windows MinGW architecture: ${{ steps.prepare.outputs.architecture }}" >&2 + exit 1 + ;; + esac + packages=( + "clang-$package_version" + "clang-libs-$package_version" + "compiler-rt-$package_version" + "llvm-$package_version" + "llvm-libs-$package_version" + "llvm-tools-$package_version" + "lld-$package_version" + "libc++-$runtime_package_version" + "libunwind-$runtime_package_version" + ) + urls=() + for package in "${packages[@]}"; do + urls+=("$repo/$prefix-$package-any.pkg.tar.zst") + done + + # libc++ supplies the required compiler runtime. Satisfy the package + # metadata while installing the exact, mutually consistent LLVM set. + assumed_cc_runtime="$prefix-cc-libs=$llvm_version" + pacman_with_retry --noconfirm -U \ + --assume-installed "$assumed_cc_runtime" \ + "${urls[@]}" + pacman_with_retry --noconfirm -S --needed \ + "$prefix-pkgconf" \ + "$prefix-sqlite3" \ + "$prefix-libuv" \ + "$prefix-gc" \ + "$prefix-libatomic_ops" \ + "$prefix-libffi" \ + "$prefix-openssl" \ + "$prefix-zlib" \ + "$prefix-cjson" \ + "$prefix-make" \ + make + + for package in clang clang-libs compiler-rt llvm llvm-libs lld llvm-tools; do + installed="$(pacman -Q "$prefix-$package" | awk '{print $2}')" + if [[ "$installed" != "$package_version" ]]; then + echo "expected $package $package_version, got $installed" >&2 + exit 1 + fi + done + for package in libc++ libunwind; do + installed="$(pacman -Q "$prefix-$package" | awk '{print $2}')" + if [[ "$installed" != "$runtime_package_version" ]]; then + echo "expected $package $runtime_package_version, got $installed" >&2 + exit 1 + fi + done + test "$(llvm-config --version)" = "$llvm_version" + test "$(clang -dumpversion)" = "$llvm_version" + ld.lld --version | grep -Eq "(^|[[:space:]])${llvm_version}([[:space:].]|$)" + + # MSYS2 does not ship pkg-config metadata for LLVM. Generate an + # explicitly versioned file from its authoritative llvm-config in + # CLANG64's normal package root. + cflags="$(llvm-config --cflags)" + ldflags="$(llvm-config --ldflags --libs all --system-libs)" + cflags="${cflags//$'\r'/}" + cflags="${cflags//$'\n'/ }" + ldflags="${ldflags//$'\r'/}" + ldflags="${ldflags//$'\n'/ }" + pc_dir="$MINGW_PREFIX/lib/pkgconfig" + mkdir -p "$pc_dir" + printf '%s\n' \ + "Name: LLVM $llvm_major" \ + "Description: LLVM $llvm_major host compiler and linker flags" \ + "Version: $llvm_version" \ + "Cflags: $cflags" \ + "Libs: $ldflags" \ + > "$pc_dir/llvm-$llvm_major.pc" + + - name: Configure independent Windows MinGW profile + if: runner.os == 'Windows' && inputs.install-dependencies == 'true' && inputs.windows-abi == 'mingw' + shell: pwsh + env: + MSYS2_LOCATION: ${{ steps.windows_mingw.outputs.msys2-location }} + run: | + $ErrorActionPreference = "Stop" + + $mingwSubdir = if ("${{ steps.prepare.outputs.architecture }}" -eq "arm64") { "clangarm64" } else { "clang64" } + $mingwRoot = Join-Path $env:MSYS2_LOCATION $mingwSubdir + $mingwBin = Join-Path $mingwRoot "bin" + $clang = Join-Path $mingwBin "clang.exe" + $pkgconf = Join-Path $mingwBin "pkgconf.exe" + $pcDir = Join-Path $mingwRoot "lib\pkgconfig" + foreach ($path in @($clang, $pkgconf, (Join-Path $pcDir "llvm-${{ inputs.llvm-version }}.pc"))) { + if (-not (Test-Path $path)) { + throw "The MinGW profile is incomplete: $path was not found" + } + } + + $target = (& $clang -dumpmachine).Trim() + $hostTargetArch = if ("${{ steps.prepare.outputs.architecture }}" -eq "arm64") { "aarch64" } else { "x86_64|amd64" } + if ($target -notmatch "^($hostTargetArch)-.*-(windows-gnu|mingw32)$") { + throw "MSYS2 Clang defaults to $target, want a matching GNU/MinGW host target" + } + + # Put one self-contained pkg-config command on PATH. Callers do not + # need PKG_CONFIG or PKG_CONFIG_PATH, and this profile never sees the + # MSVC vcpkg tree. + $nativeTools = Join-Path $env:RUNNER_TEMP "llgo-mingw-tools" + $profilePC = Join-Path $env:RUNNER_TEMP "llgo-pkgconfig" + New-Item -ItemType Directory -Force $nativeTools, $profilePC | Out-Null + $pkgConfig = Join-Path $nativeTools "pkg-config.cmd" + @" + @echo off + "$pkgconf" --dont-define-prefix "--with-path=$profilePC" "--with-path=$pcDir" %* + "@ | Set-Content -Encoding ascii $pkgConfig + $pkgConfigShell = Join-Path $nativeTools "pkg-config" + $pkgconfUnix = $pkgconf.Replace('\', '/') + $profilePCUnix = $profilePC.Replace('\', '/') + $pcDirUnix = $pcDir.Replace('\', '/') + @" + #!/usr/bin/env bash + set -euo pipefail + MSYS2_ARG_CONV_EXCL='*' exec "$pkgconfUnix" --dont-define-prefix "--with-path=$profilePCUnix" "--with-path=$pcDirUnix" "`$@" + "@ | Set-Content -Encoding ascii $pkgConfigShell + + foreach ($package in @("llvm-${{ inputs.llvm-version }}", "bdw-gc", "libffi", "libuv", "openssl", "libcjson", "sqlite3", "zlib")) { + & $pkgConfig --modversion $package + if ($LASTEXITCODE -ne 0) { + throw "MinGW pkg-config metadata for $package is unavailable" + } + } + + Add-Content -Encoding utf8 $env:GITHUB_ENV "CC=clang" + Add-Content -Encoding utf8 $env:GITHUB_ENV "CXX=clang++" + Add-Content -Encoding utf8 $env:GITHUB_ENV "CGO_ENABLED=1" + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_MINGW_HOST_PC_DIR=$pcDir" + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_MINGW_HOST_PKGCONF=$pkgconf" + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_MINGW_HOST_ROOT=$mingwRoot" + if ("${{ steps.prepare.outputs.architecture }}" -ne "amd64") { + $lldbRoot = Join-Path $env:RUNNER_TOOL_CACHE "llgo-windows-support\22.1.8\${{ steps.prepare.outputs.architecture }}\lldb" + $lldb = Join-Path $lldbRoot "bin\lldb.exe" + if (-not (Test-Path $lldb)) { + throw "lldb.exe was not found for Windows ${{ steps.prepare.outputs.architecture }}" + } + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_LLDB=$lldb" + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_WINDOWS_LLDB_PYTHONPATH=$(Join-Path $lldbRoot 'lib\site-packages')" + } + Add-Content -Encoding utf8 $env:GITHUB_PATH $mingwBin + # Keep the fixed-search-path wrapper ahead of CLANG64's pkg-config.exe; + # setup-demo-deps adds profile-local metadata below RUNNER_TEMP. + Add-Content -Encoding utf8 $env:GITHUB_PATH $nativeTools + + - name: Refresh Homebrew metadata on Apple Silicon + if: inputs.install-dependencies == 'true' && runner.os == 'macOS' && runner.arch == 'ARM64' + shell: bash + env: + HOMEBREW_NO_AUTO_UPDATE: 1 + run: brew update + + - name: Install macOS dependencies + if: inputs.install-dependencies == 'true' && runner.os == 'macOS' + shell: bash + env: + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK: 1 + run: | + # Intel macOS is a Homebrew Tier 3 configuration and newly published + # formulae may have no Intel bottle. Keep its runner-image metadata and + # installed formulae; Apple Silicon refreshes metadata in the step above. + brew_install_missing() { + local formula + local missing=() + for formula in "$@"; do + if ! brew list --versions "${formula}" >/dev/null; then + missing+=("${formula}") + fi + done + if (( ${#missing[@]} > 0 )); then + brew install "${missing[@]}" + fi + } + + # Install LLVM if requested + if [[ "${{ inputs.install-dependencies }}" == "true" ]]; then + llvm_formula="llvm@${{inputs.llvm-version}}" + lld_formula="lld@${{inputs.llvm-version}}" - - name: 'Setup LLGo' - run: node $GITHUB_ACTION_PATH/dist/index.js + # GitHub macOS runners may pre-link another LLVM/LLD version (for example llvm@18), + # which makes lld@ fail during Homebrew's automatic link step. + while IFS= read -r formula; do + case "${formula}" in + llvm|llvm@*|lld|lld@*) + if [[ "${formula}" != "${llvm_formula}" && "${formula}" != "${lld_formula}" ]]; then + brew unlink "${formula}" || true + fi + ;; + esac + done < <(brew list --formula) + + brew_install_missing "${llvm_formula}" "${lld_formula}" + brew link --force --overwrite "${llvm_formula}" "${lld_formula}" + llvm_bin="$(brew --prefix "${llvm_formula}")/bin" + lld_bin="$(brew --prefix "${lld_formula}")/bin" + echo "${llvm_bin}" >> "$GITHUB_PATH" + echo "${lld_bin}" >> "$GITHUB_PATH" + + # Print resolved toolchain versions for CI diagnostics. + echo "LLVM/LLD versions:" + brew list --versions "${llvm_formula}" "${lld_formula}" + clang_version="$("${llvm_bin}/clang" --version | head -n 1)" + lld_version="$("${lld_bin}/ld.lld" --version | head -n 1)" + echo "clang: ${clang_version}" + echo "ld.lld: ${lld_version}" + test "$("${llvm_bin}/clang" -dumpversion | cut -d. -f1)" = "${{inputs.llvm-version}}" + "${lld_bin}/ld.lld" --version | grep -Eq "(^|[[:space:]])${{inputs.llvm-version}}\." + echo "linked ld.lld path: $(command -v ld.lld || true)" + fi + + # Install common dependencies + brew_install_missing bdw-gc openssl libffi libuv + brew link --overwrite libffi + + - name: Install Linux dependencies + if: runner.os == 'Linux' && inputs.install-dependencies == 'true' + shell: bash + env: + LLVM_VERSION: ${{ inputs.llvm-version }} + run: | + echo "deb http://apt.llvm.org/$(lsb_release -cs)/ llvm-toolchain-$(lsb_release -cs)-$LLVM_VERSION main" | sudo tee /etc/apt/sources.list.d/llvm.list + wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add - + sudo apt-get update + sudo apt-get install -y "clang-$LLVM_VERSION" "libclang-$LLVM_VERSION-dev" "lld-$LLVM_VERSION" pkg-config libgc-dev libssl-dev zlib1g-dev libffi-dev libuv1-dev + echo "/usr/lib/llvm-$LLVM_VERSION/bin" >> "$GITHUB_PATH" + - name: Build or activate LLGo + id: install shell: bash + run: node "$GITHUB_ACTION_PATH/dist/index.js" env: - INPUT_LLGO_VERSION: ${{ inputs.llgo-version }} - # INPUT_LLGO_VERSION_FILE: ${{ inputs.llgo-version-file }} + SETUP_LLGO_PHASE: install + SETUP_LLGO_SOURCE: ${{ steps.prepare.outputs.install-dir }} + SETUP_LLGO_METHOD: ${{ steps.prepare.outputs.install-method }} + SETUP_LLGO_REF: ${{ steps.prepare.outputs.llgo-ref }} + - name: Activate Windows MSVC target + if: runner.os == 'Windows' && inputs.install-dependencies == 'true' && inputs.windows-abi == 'msvc' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + . (Join-Path $env:SETUP_LLGO_ACTION_PATH "scripts\windows\msvc-target.ps1") + $target = Enter-LLGoVisualStudio -GoArch "${{ steps.prepare.outputs.architecture }}" + + if ($env:LLGO_WINDOWS_TARGET_TRIPLE -ne $target.Triple) { + throw "Configured target $env:LLGO_WINDOWS_TARGET_TRIPLE does not match $($target.Triple)" + } + if ($env:LLGO_WINDOWS_VCPKG_TRIPLET -ne $target.VcpkgTriplet) { + throw "Configured vcpkg triplet $env:LLGO_WINDOWS_VCPKG_TRIPLET does not match $($target.VcpkgTriplet)" + } + if (-not (Test-Path (Join-Path $env:LLGO_WINDOWS_VCPKG_ROOT "include\gc\gc.h"))) { + throw "The $($target.VcpkgTriplet) dependency installation is incomplete" + } + + Export-LLGoVisualStudioEnvironment + Add-Content -Encoding utf8 $env:GITHUB_ENV "CC=clang --target=$($target.Triple) -fuse-ld=lld -fms-runtime-lib=dll" + Add-Content -Encoding utf8 $env:GITHUB_ENV "CXX=clang++ --target=$($target.Triple) -fuse-ld=lld -fms-runtime-lib=dll -std=c++17" + Add-Content -Encoding utf8 $env:GITHUB_ENV "GOOS=windows" + Add-Content -Encoding utf8 $env:GITHUB_ENV "GOARCH=$($target.GoArch)" + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_WINDOWS_TARGET_ACTIVE=1" + Add-Content -Encoding utf8 $env:GITHUB_PATH (Join-Path $env:LLGO_WINDOWS_VCPKG_ROOT "bin") + + - name: Activate Windows GNU target + if: runner.os == 'Windows' && inputs.install-dependencies == 'true' && inputs.windows-abi == 'mingw' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $goArch = "${{ steps.prepare.outputs.architecture }}" + $targetArch = switch ($goArch) { + "386" { "i686" } + "amd64" { "x86_64|amd64" } + "arm64" { "aarch64" } + default { throw "Unsupported Windows GNU target architecture: $goArch" } + } + $cc = "clang" + $cxx = "clang++" + + if ($goArch -eq "386") { + foreach ($name in @("LLGO_MINGW_TARGET_BIN", "LLGO_MINGW_TARGET_RUNTIME_BIN", "LLGO_MINGW_TARGET_TOOLS", "LLGO_MINGW_TARGET_TRIPLE", "LLGO_MINGW_TARGET_VCPKG_ROOT")) { + $value = [Environment]::GetEnvironmentVariable($name) + if (-not $value) { + throw "The standalone llvm-mingw target did not export $name" + } + } + $compiler = Join-Path $env:LLGO_MINGW_TARGET_BIN "i686-w64-mingw32-clang.exe" + $compilerXX = Join-Path $env:LLGO_MINGW_TARGET_BIN "i686-w64-mingw32-clang++.exe" + foreach ($path in @($compiler, $compilerXX)) { + if (-not (Test-Path $path)) { + throw "The standalone llvm-mingw target compiler was not found: $path" + } + } + $target = (& $compiler -dumpmachine).Trim() + if ($target -notmatch "^$targetArch-.*-(windows-gnu|mingw32)$") { + throw "llvm-mingw reports $target, want windows/$goArch GNU" + } + # GITHUB_PATH prepends entries in reverse write order. Keep the + # target-selecting clang/pkg-config wrappers ahead of the bundle's + # unqualified x64 host commands for all subsequent steps. + Add-Content -Encoding utf8 $env:GITHUB_PATH $env:LLGO_MINGW_TARGET_BIN + Add-Content -Encoding utf8 $env:GITHUB_PATH $env:LLGO_MINGW_TARGET_RUNTIME_BIN + Add-Content -Encoding utf8 $env:GITHUB_PATH $env:LLGO_MINGW_TARGET_TOOLS + Add-Content -Encoding utf8 $env:GITHUB_PATH (Join-Path $env:LLGO_MINGW_TARGET_VCPKG_ROOT "bin") + # Keep the wrappers for interactive shells, but let LLGo invoke the + # drivers directly. A normal runtime link can exceed cmd.exe's 8191 + # character limit while remaining below LLGo's 30 KiB response-file + # threshold. + $cc = $compiler + $cxx = $compilerXX + } else { + $target = (& clang.exe -dumpmachine).Trim() + if ($target -notmatch "^$targetArch-.*-(windows-gnu|mingw32)$") { + throw "MSYS2 Clang reports $target, want windows/$goArch GNU" + } + } + + Add-Content -Encoding utf8 $env:GITHUB_ENV "CC=$cc" + Add-Content -Encoding utf8 $env:GITHUB_ENV "CXX=$cxx" + Add-Content -Encoding utf8 $env:GITHUB_ENV "GOOS=windows" + Add-Content -Encoding utf8 $env:GITHUB_ENV "GOARCH=$goArch" + Add-Content -Encoding utf8 $env:GITHUB_ENV "LLGO_WINDOWS_TARGET_ACTIVE=1" diff --git a/dist/index.js b/dist/index.js index cb79f74..fbf9020 100644 --- a/dist/index.js +++ b/dist/index.js @@ -5986,6 +5986,125 @@ function version(uuid) { var _default = version; exports["default"] = _default; +/***/ }), + +/***/ 6232: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.verifyChecksum = exports.download = void 0; +const crypto_1 = __nccwpck_require__(6113); +const fs_1 = __importDefault(__nccwpck_require__(7147)); +const https_1 = __importDefault(__nccwpck_require__(5687)); +const promises_1 = __nccwpck_require__(4845); +const network_1 = __nccwpck_require__(2280); +async function download(url, destination) { + await (0, network_1.retryNetwork)(async () => downloadOnce(url, destination)); +} +exports.download = download; +async function downloadOnce(url, destination, redirects = 0) { + const parsed = new URL(url); + const trustedHosts = [ + 'github.com', + 'objects.githubusercontent.com', + 'release-assets.githubusercontent.com' + ]; + if (parsed.protocol !== 'https:' || + !trustedHosts.includes(parsed.hostname) || + parsed.username || + parsed.password || + redirects > 5) + throw new Error(`Invalid download URL or too many redirects: ${url}`); + const response = await new Promise((resolve, reject) => { + const request = https_1.default.get(url, { headers: { 'User-Agent': 'setup-llgo' } }, resolve); + request.setTimeout(120000, () => request.destroy((0, network_1.downloadTimeout)())); + request.on('error', reject); + }); + if (response.statusCode && + [301, 302, 303, 307, 308].includes(response.statusCode) && + response.headers.location) { + response.resume(); + await downloadOnce(new URL(response.headers.location, url).href, destination, redirects + 1); + return; + } + if (response.statusCode !== 200) { + response.resume(); + throw new Error(`Download failed (${response.statusCode}): ${url}`); + } + // Keep the inactivity timeout active while the body is streamed as well. + response.setTimeout(120000, () => response.destroy((0, network_1.downloadTimeout)())); + await (0, promises_1.pipeline)(response, fs_1.default.createWriteStream(destination)); +} +async function verifyChecksum(archive, filename, checksums) { + const entry = checksums + .split(/\r?\n/) + .find(line => line.trim().split(/\s+/)[1]?.replace(/^\*/, '') === filename); + const expected = entry?.trim().split(/\s+/)[0]; + if (!expected || !/^[a-f\d]{64}$/i.test(expected)) + throw new Error(`No SHA-256 checksum for ${filename}`); + const hash = (0, crypto_1.createHash)('sha256'); + for await (const chunk of fs_1.default.createReadStream(archive)) + hash.update(chunk); + if (hash.digest('hex') !== expected.toLowerCase()) + throw new Error(`SHA-256 mismatch for ${filename}`); +} +exports.verifyChecksum = verifyChecksum; + + +/***/ }), + +/***/ 6144: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +const core = __importStar(__nccwpck_require__(2186)); +const install_1 = __nccwpck_require__(1649); +async function run() { + try { + if (process.env.SETUP_LLGO_PHASE === 'prepare') + await (0, install_1.prepareLLGo)(); + else if (process.env.SETUP_LLGO_PHASE === 'install') + (0, install_1.installLLGo)(process.env.SETUP_LLGO_SOURCE || '', process.env.SETUP_LLGO_METHOD || ''); + else + throw new Error('Unknown setup-llgo phase'); + } + catch (error) { + core.setFailed(error instanceof Error ? error.message : String(error)); + } +} +void run(); + + /***/ }), /***/ 1649: @@ -6020,159 +6139,411 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.parseGopVersionFile = exports.selectVersion = exports.installLLGo = void 0; +exports.installLLGo = exports.prepareLLGo = exports.installRelease = exports.checkoutLLGo = exports.archiveTool = void 0; const core = __importStar(__nccwpck_require__(2186)); -const semver = __importStar(__nccwpck_require__(1383)); +const child_process_1 = __nccwpck_require__(2081); const fs_1 = __importDefault(__nccwpck_require__(7147)); -const path_1 = __importDefault(__nccwpck_require__(1017)); const os_1 = __importDefault(__nccwpck_require__(2037)); -const child_process_1 = __nccwpck_require__(2081); -const REPO = 'https://github.com/goplus/llgo.git'; -/** - * The main function for the action. - * @returns {Promise} Resolves when the action is complete. - */ -async function installLLGo() { +const path_1 = __importDefault(__nccwpck_require__(1017)); +const resolve_1 = __nccwpck_require__(1077); +const platform_1 = __nccwpck_require__(2999); +const download_1 = __nccwpck_require__(6232); +const network_1 = __nccwpck_require__(2280); +const repository = 'https://github.com/xgo-dev/llgo.git'; +function archiveTool() { + // Git for Windows also ships GNU tar, which treats C: as a remote host + // and cannot unpack ZIPs. Use the native bsdtar explicitly. + return process.platform === 'win32' + ? path_1.default.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') + : 'tar'; +} +exports.archiveTool = archiveTool; +function git(args, cwd) { + return (0, child_process_1.execFileSync)('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } + }).trim(); +} +function checkoutLLGo(selected, sourceDir, repo = repository) { + git(['init', '--quiet'], sourceDir); + git(['remote', 'add', 'origin', repo], sourceDir); + let revision = selected.sha; + if (selected.kind === 'commit' && revision.length < 40) { + // A server cannot fetch arbitrary abbreviated object IDs. Resolve against + // fetched history, letting Git reject unknown or ambiguous abbreviations. + git([ + 'fetch', + '--quiet', + '--filter=blob:none', + 'origin', + '+refs/heads/*:refs/remotes/origin/*', + '+refs/tags/*:refs/tags/*' + ], sourceDir); + revision = git(['rev-parse', '--verify', `${revision}^{commit}`], sourceDir); + } + else { + git(['fetch', '--quiet', '--depth=1', 'origin', revision], sourceDir); + } + if (selected.kind === 'tag') + git(['update-ref', selected.ref, revision], sourceDir); + git(['checkout', '--quiet', '--detach', revision], sourceDir); + return git(['rev-parse', 'HEAD'], sourceDir); +} +exports.checkoutLLGo = checkoutLLGo; +async function installRelease(selected, platform, destination) { + if (selected.kind !== 'tag') + return false; + const tag = selected.ref.slice('refs/tags/'.length); + const headers = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'setup-llgo' + }; + const token = core.getInput('token'); + if (token) + headers.Authorization = `Bearer ${token}`; + const release = await (0, network_1.retryNetwork)(async () => { + const response = await fetch(`https://api.github.com/repos/xgo-dev/llgo/releases/tags/${encodeURIComponent(tag)}`, { headers, signal: AbortSignal.timeout(120000) }); + if (response.status === 404) + return undefined; + if (!response.ok) + throw new Error(`Release lookup failed: HTTP ${response.status}`); + return (await response.json()); + }); + if (!release) + return false; + const filename = (0, platform_1.releaseAsset)(tag, platform); + const asset = release.assets.find(item => item.name === filename); + if (!asset) + return false; + const checksumAsset = release.assets.find(item => item.name === `llgo${(0, platform_1.releaseVersion)(tag)}.checksums.txt`); + if (!checksumAsset) + throw new Error(`Release ${tag} is missing its checksum manifest`); + const archive = path_1.default.join(destination, filename); + const checksums = path_1.default.join(destination, 'checksums.txt'); + await (0, download_1.download)(checksumAsset.browser_download_url, checksums); + await (0, download_1.download)(asset.browser_download_url, archive); + await (0, download_1.verifyChecksum)(archive, filename, fs_1.default.readFileSync(checksums, 'utf8')); + (0, child_process_1.execFileSync)(archiveTool(), ['-xf', archive, '-C', destination], { + stdio: 'inherit' + }); + fs_1.default.unlinkSync(archive); + fs_1.default.unlinkSync(checksums); + core.info(`Installed release asset ${filename}`); + return true; +} +exports.installRelease = installRelease; +async function prepareLLGo() { + const platform = (0, platform_1.platformFor)(process.platform, core.getInput('architecture') || process.env.RUNNER_ARCH || process.arch, core.getInput('windows-abi') || 'msvc'); + const method = core.getInput('install-method') || 'auto'; + if (!['auto', 'source', 'release'].includes(method)) + throw new Error(`Unknown install-method: ${method}`); + const refs = (0, resolve_1.parseRemoteRefs)(git(['ls-remote', '--heads', '--tags', repository])); + const selected = (0, resolve_1.resolveVersion)((0, resolve_1.versionInput)(core.getInput('llgo-version'), core.getInput('llgo-version-file')), refs); + // Own only a unique temporary directory; never touch the user's ~/workdir. + const sourceDir = fs_1.default.mkdtempSync(path_1.default.join(process.env.RUNNER_TEMP || os_1.default.tmpdir(), 'setup-llgo-')); try { - const versionSpec = resolveVersionInput() || ''; - const tagVersions = semver.rsort(fetchTags().filter(v => semver.valid(v))); - let version = null; - if (!versionSpec || versionSpec === 'latest') { - version = tagVersions[0]; - core.warning(`No llgo-version specified, using latest version: ${version}`); - } - else { - version = semver.maxSatisfying(tagVersions, versionSpec); - if (!version) { - core.warning(`No llgo-version found that satisfies '${versionSpec}', trying branches...`); - const branchVersions = fetchBranches(); - if (!branchVersions.includes(versionSpec)) { - throw new Error(`No llgo-version found that satisfies '${versionSpec}' in branches or tags`); - } - version = ''; - } - } - let checkoutVersion = ''; - if (version) { - core.info(`Selected version ${version} by spec ${versionSpec}`); - checkoutVersion = `v${version}`; - core.setOutput('llgo-version-verified', true); - } - else { - core.warning(`Unable to find a version that satisfies the version spec '${versionSpec}', trying branches...`); - checkoutVersion = versionSpec; - core.setOutput('llgo-version-verified', false); - } - const llgoDir = cloneBranchOrTag(checkoutVersion); - install(llgoDir); - // if (version) { - // checkVersion(version) - // } - core.setOutput('llgo-version', llgoVersion()); + const prebuilt = method !== 'source' && + (await installRelease(selected, platform, sourceDir)); + if (!prebuilt && method === 'release') + throw new Error(`No release asset for ${selected.ref} on ${platform.os}/${platform.arch}/${platform.abi}`); + const revision = prebuilt ? selected.sha : checkoutLLGo(selected, sourceDir); + core.info(`Selected ${selected.ref} at ${revision} (${prebuilt ? 'release' : 'source'})`); + core.setOutput('install-dir', sourceDir); + core.setOutput('install-method', prebuilt ? 'release' : 'source'); + core.setOutput('llgo-revision', revision); + core.setOutput('llgo-ref', selected.ref); + core.setOutput('llgo-version-verified', selected.kind === 'tag'); + core.setOutput('architecture', platform.arch); + core.exportVariable('SETUP_LLGO_ACTION_PATH', process.env.GITHUB_ACTION_PATH || ''); } catch (error) { - // Fail the workflow run if an error occurs - if (error instanceof Error) - core.setFailed(error.message); - } + fs_1.default.rmSync(sourceDir, { recursive: true, force: true }); + throw error; + } +} +exports.prepareLLGo = prepareLLGo; +function installLLGo(sourceDir, method) { + if (!sourceDir) + throw new Error('The LLGo installation directory is required'); + const env = { ...process.env, LLGO_ROOT: sourceDir }; + const executable = process.platform === 'win32' ? 'llgo.exe' : 'llgo'; + if (method === 'source') + (0, child_process_1.execFileSync)('go', ['build', '-o', `bin/${executable}`, './cmd/llgo'], { + cwd: sourceDir, + env, + stdio: 'inherit' + }); + const binary = path_1.default.join(sourceDir, 'bin', executable); + const version = (0, child_process_1.execFileSync)(binary, ['version'], { + env, + encoding: 'utf8' + }).trim(); + if (method === 'release') + (0, resolve_1.verifyInstalledVersion)(version, process.env.SETUP_LLGO_REF || ''); + core.exportVariable('LLGO_ROOT', sourceDir); + core.addPath(path_1.default.dirname(binary)); + core.info(version); + core.setOutput('llgo-version', version); } exports.installLLGo = installLLGo; -function selectVersion(versions, versionSpec) { - const sortedVersions = semver.rsort(versions.filter(v => semver.valid(v))); - if (!versionSpec || versionSpec === 'latest') { - return sortedVersions[0]; - } - return semver.maxSatisfying(sortedVersions, versionSpec); -} -exports.selectVersion = selectVersion; -function cloneBranchOrTag(versionSpec) { - // git clone https://github.com/llgo/llgo.git with tag $versionSpec to $HOME/workdir/llgo - const workDir = path_1.default.join(os_1.default.homedir(), 'workdir'); - if (fs_1.default.existsSync(workDir)) { - fs_1.default.rmSync(workDir, { recursive: true }); - } - fs_1.default.mkdirSync(workDir); - core.info(`Cloning llgo ${versionSpec} to ${workDir} ...`); - const cmd = `git clone --depth 1 --branch ${versionSpec} ${REPO}`; - (0, child_process_1.execSync)(cmd, { cwd: workDir, stdio: 'inherit' }); - core.info('llgo cloned'); - return path_1.default.join(workDir, 'llgo'); -} -function install(llgoDir) { - core.info(`Installing llgo ${llgoDir} ...`); - const bin = path_1.default.join(os_1.default.homedir(), 'bin'); - (0, child_process_1.execSync)('go install ./cmd/llgo', { - cwd: llgoDir, - stdio: 'inherit', - env: { - ...process.env, - GOBIN: bin + + +/***/ }), + +/***/ 2280: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.downloadTimeout = exports.retryNetwork = void 0; +const core = __importStar(__nccwpck_require__(2186)); +function isTransientNetworkError(error) { + for (let depth = 0; error instanceof Error && depth < 3; depth++) { + if (error.name === 'TimeoutError' || + [ + 'ECONNRESET', + 'ECONNREFUSED', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'ENOTFOUND', + 'ENETUNREACH', + 'EHOSTUNREACH', + 'ERR_STREAM_PREMATURE_CLOSE', + 'UND_ERR_SOCKET', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_BODY_TIMEOUT' + ].includes(error.code || '')) + return true; + error = error.cause; + } + return false; +} +async function retryNetwork(operation) { + for (let attempt = 0;; attempt++) { + try { + return await operation(); } - }); - core.addPath(bin); - core.info('llgo installed'); -} -// function checkVersion(versionSpec: string): string { -// core.info(`Testing llgo ${versionSpec} ...`) -// const actualVersion = llgoVersion() -// if (actualVersion !== versionSpec) { -// throw new Error( -// `Installed llgo version ${actualVersion} does not match expected version ${versionSpec}` -// ) -// } -// core.info(`Installed llgo version ${actualVersion}`) -// return actualVersion -// } -function llgoVersion() { - const out = (0, child_process_1.execSync)('llgo version', { env: process.env }); - return out.toString().trim().replace(/^v/, ''); -} -function fetchTags() { - const cmd = `git -c versionsort.suffix=- ls-remote --tags --sort=v:refname ${REPO}`; - const out = (0, child_process_1.execSync)(cmd).toString(); - const versions = out - .split('\n') - .filter(s => s) - .map(s => s.split('\t')[1].replace('refs/tags/', '')) - .map(s => s.replace(/^v/, '')); - return versions; -} -function fetchBranches() { - const cmd = `git -c versionsort.suffix=- ls-remote --heads --sort=v:refname ${REPO}`; - const out = (0, child_process_1.execSync)(cmd).toString(); - const versions = out - .split('\n') - .filter(s => s) - .map(s => s.split('\t')[1].replace('refs/heads/', '')); - return versions; -} -function resolveVersionInput() { - let version = process.env['INPUT_LLGO_VERSION']; - const versionFilePath = process.env['INPUT_LLGO_VERSION_FILE']; - if (version && versionFilePath) { - core.warning('Both llgo-version and llgo-version-file inputs are specified, only llgo-version will be used'); - } - if (version) { - return version; - } - if (versionFilePath) { - if (!fs_1.default.existsSync(versionFilePath)) { - throw new Error(`The specified llgo version file at: ${versionFilePath} does not exist`); + catch (error) { + // HTTP responses, bad checksums and filesystem errors are not transient + // transport failures. In particular, never retry a missing release. + if (attempt === 2 || !isTransientNetworkError(error)) + throw error; + core.info(`Network transfer failed; retrying (attempt ${attempt + 2}/3)`); + await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt)); } - version = parseGopVersionFile(versionFilePath); } - return version; } -function parseGopVersionFile(versionFilePath) { - const contents = fs_1.default.readFileSync(versionFilePath).toString(); - if (path_1.default.basename(versionFilePath) === 'go.mod' || - path_1.default.basename(versionFilePath) === 'go.work') { - const match = contents.match(/\/\/ llgo (\d+(\.\d+)*)/m); - return match ? match[1] : ''; +exports.retryNetwork = retryNetwork; +function downloadTimeout() { + return Object.assign(new Error('Download timed out'), { code: 'ETIMEDOUT' }); +} +exports.downloadTimeout = downloadTimeout; + + +/***/ }), + +/***/ 2999: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.releaseAsset = exports.releaseVersion = exports.platformFor = void 0; +function platformFor(os, architecture, abi) { + const arch = { x64: 'amd64', x86_64: 'amd64', aarch64: 'arm64' }[architecture.toLowerCase()] || architecture.toLowerCase(); + if (!['linux', 'darwin', 'win32'].includes(os)) + throw new Error(`Unsupported OS: ${os}`); + if (!['amd64', 'arm64'].includes(arch)) + throw new Error(`Unsupported architecture: ${architecture}`); + if (os === 'win32' && !['msvc', 'mingw'].includes(abi)) + throw new Error(`Unsupported Windows ABI: ${abi}`); + return { + os: os === 'win32' ? 'windows' : os, + arch, + abi: os === 'win32' ? abi : '' + }; +} +exports.platformFor = platformFor; +function releaseVersion(tag) { + return tag.replace(/^v/, ''); +} +exports.releaseVersion = releaseVersion; +function releaseAsset(tag, platform) { + const suffix = platform.os === 'windows' ? `-${platform.abi}.zip` : '.tar.gz'; + return `llgo${releaseVersion(tag)}.${platform.os}-${platform.arch}${suffix}`; +} +exports.releaseAsset = releaseAsset; + + +/***/ }), + +/***/ 1077: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; } - return contents.trim(); + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.verifyInstalledVersion = exports.versionInput = exports.resolveVersion = exports.parseRemoteRefs = void 0; +const semver = __importStar(__nccwpck_require__(1383)); +const fs_1 = __importDefault(__nccwpck_require__(7147)); +const path_1 = __importDefault(__nccwpck_require__(1017)); +function parseRemoteRefs(output) { + const refs = new Map(); + const peeled = new Map(); + for (const line of output.split('\n')) { + const [sha, ref] = line.trim().split(/\s+/); + if (!sha || !ref) + continue; + if (ref.endsWith('^{}')) + peeled.set(ref.slice(0, -3), sha); + else if (ref.startsWith('refs/tags/')) + refs.set(ref, { name: ref.slice(10), sha, kind: 'tag' }); + else if (ref.startsWith('refs/heads/')) + refs.set(ref, { name: ref.slice(11), sha, kind: 'branch' }); + } + for (const [ref, sha] of peeled) { + const tag = refs.get(ref); + if (tag) + tag.sha = sha; + } + return [...refs.values()]; +} +exports.parseRemoteRefs = parseRemoteRefs; +function selected(ref) { + return { + ref: `refs/${ref.kind === 'tag' ? 'tags' : 'heads'}/${ref.name}`, + sha: ref.sha, + kind: ref.kind + }; +} +function glob(pattern, name) { + const escaped = pattern.replace(/[|\\{}()[\]^$+?.*]/g, char => { + if (char === '*') + return '.*'; + if (char === '?') + return '.'; + return `\\${char}`; + }); + return new RegExp(`^${escaped}$`).test(name); +} +function resolveVersion(input, refs) { + const spec = input.trim() || 'latest'; + const tags = refs.filter(ref => ref.kind === 'tag'); + const branches = refs.filter(ref => ref.kind === 'branch'); + if (spec.startsWith('refs/')) { + const exact = refs.find(ref => selected(ref).ref === spec); + if (!exact) + throw new Error(`Unknown LLGo ref: ${spec}`); + return selected(exact); + } + const exactTag = tags.find(ref => ref.name === spec || ref.name === `v${spec}`); + if (exactTag) + return selected(exactTag); + const exactBranch = branches.find(ref => ref.name === spec); + if (exactBranch) + return selected(exactBranch); + if (/^[a-f\d]{7,40}$/i.test(spec)) + return { kind: 'commit', ref: spec, sha: spec.toLowerCase() }; + const range = semver.validRange(spec === 'latest' ? '*' : spec); + const matches = range !== null + ? tags.filter(ref => semver.valid(ref.name) && semver.satisfies(ref.name, range)) + : tags.filter(ref => glob(spec, ref.name)); + const versions = matches.filter(ref => semver.valid(ref.name)); + versions.sort((a, b) => semver.rcompare(a.name, b.name)); + if (versions.length) + return selected(versions[0]); + if (matches.length === 1) + return selected(matches[0]); + if (matches.length > 1) + throw new Error(`Ambiguous LLGo tag pattern: ${spec}`); + const branchMatches = branches.filter(ref => glob(spec, ref.name)); + if (branchMatches.length === 1) + return selected(branchMatches[0]); + if (branchMatches.length > 1) + throw new Error(`Ambiguous LLGo branch pattern: ${spec}`); + throw new Error(`No LLGo version, tag, branch or commit matches: ${spec}`); +} +exports.resolveVersion = resolveVersion; +function versionInput(explicit, file) { + if (explicit.trim()) + return explicit.trim(); + if (!file) + return ''; + const contents = fs_1.default.readFileSync(file, 'utf8').trim(); + if (['go.mod', 'go.work'].includes(path_1.default.basename(file))) { + const match = contents.match(/^\s*\/\/\s*llgo\s+(.+?)\s*$/m); + if (!match) + throw new Error(`No // llgo version directive in ${file}`); + return match[1]; + } + if (!contents) + throw new Error(`Empty LLGo version file: ${file}`); + return contents; +} +exports.versionInput = versionInput; +function verifyInstalledVersion(actual, ref) { + if (!ref.startsWith('refs/tags/')) + return; + const expected = semver.valid(ref.slice('refs/tags/'.length)); + if (!expected) + return; + const reported = actual.match(/^llgo v?(\S+)\s/); + if (!reported || reported[1] !== expected) + throw new Error(`Installed LLGo version ${actual} does not match ${ref}`); } -exports.parseGopVersionFile = parseGopVersionFile; +exports.verifyInstalledVersion = verifyInstalledVersion; /***/ }), @@ -6257,6 +6628,14 @@ module.exports = require("path"); /***/ }), +/***/ 4845: +/***/ ((module) => { + +"use strict"; +module.exports = require("stream/promises"); + +/***/ }), + /***/ 4404: /***/ ((module) => { @@ -6311,25 +6690,12 @@ module.exports = require("util"); /******/ if (typeof __nccwpck_require__ !== 'undefined') __nccwpck_require__.ab = __dirname + "/"; /******/ /************************************************************************/ -var __webpack_exports__ = {}; -// This entry need to be wrapped in an IIFE because it need to be in strict mode. -(() => { -"use strict"; -var exports = __webpack_exports__; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -/** - * The entrypoint for the action. - */ -const install_1 = __nccwpck_require__(1649); -async function run() { - await (0, install_1.installLLGo)(); -} -// eslint-disable-next-line @typescript-eslint/no-floating-promises -run(); - -})(); - -module.exports = __webpack_exports__; +/******/ +/******/ // startup +/******/ // Load entry module and return exports +/******/ // This entry module is referenced by other modules so it can't be inlined +/******/ var __webpack_exports__ = __nccwpck_require__(6144); +/******/ module.exports = __webpack_exports__; +/******/ /******/ })() ; \ No newline at end of file diff --git a/package.json b/package.json index 3ee1233..b6c388a 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "lint": "npx eslint . -c ./.github/linters/.eslintrc.yml", "package": "ncc build src/index.ts --license licenses.txt", "package:watch": "npm run package -- --watch", - "test": "(jest && make-coverage-badge --output-path ./badges/coverage.svg) || make-coverage-badge --output-path ./badges/coverage.svg", + "test": "jest --runInBand", "all": "npm run format:write && npm run lint && npm run test && npm run package" }, "license": "MIT", diff --git a/scripts/pacman_retry.sh b/scripts/pacman_retry.sh new file mode 100644 index 0000000..4c18511 --- /dev/null +++ b/scripts/pacman_retry.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +# LLGO_PACMAN_MAX_ATTEMPTS: total transaction attempts (default: 3). +# LLGO_PACMAN_RETRY_DELAY_SECONDS: delay between attempts (default: 5). +# Retries intentionally accept any pacman failure, including permanent errors; +# the bounded attempt count limits the delay before reporting exhaustion. + +pacman_command() { + command pacman "$@" +} + +pacman_retry_sleep() { + command sleep "$1" +} + +pacman_with_retry() { + local max_attempts="${LLGO_PACMAN_MAX_ATTEMPTS:-3}" + local retry_delay="${LLGO_PACMAN_RETRY_DELAY_SECONDS:-5}" + if ! [[ "${max_attempts}" =~ ^[1-9][0-9]*$ ]]; then + echo "LLGO_PACMAN_MAX_ATTEMPTS must be a positive integer" >&2 + return 2 + fi + if ! [[ "${retry_delay}" =~ ^[0-9]+$ ]]; then + echo "LLGO_PACMAN_RETRY_DELAY_SECONDS must be a non-negative integer" >&2 + return 2 + fi + if (( $# == 0 )); then + echo "no pacman arguments specified" >&2 + return 2 + fi + + local attempt + for ((attempt = 1; attempt <= max_attempts; attempt++)); do + if pacman_command "$@"; then + return 0 + fi + if (( attempt < max_attempts )); then + echo "pacman failed (attempt ${attempt}/${max_attempts}); retrying the package transaction" >&2 + pacman_retry_sleep "${retry_delay}" + fi + done + + echo "pacman failed after ${max_attempts} attempts" >&2 + return 1 +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + set -euo pipefail + pacman_with_retry "$@" +fi diff --git a/scripts/pacman_retry_test.sh b/scripts/pacman_retry_test.sh new file mode 100644 index 0000000..20f37c8 --- /dev/null +++ b/scripts/pacman_retry_test.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/pacman_retry.sh +source "${script_dir}/pacman_retry.sh" + +fail() { + echo "$1" >&2 + exit 1 +} + +attempts=0 +sleeps=0 +last_sleep="" +last_args=() + +# Called indirectly by pacman_with_retry. +# shellcheck disable=SC2317,SC2329 +pacman_command() { + attempts=$((attempts + 1)) + last_args=("$@") + (( attempts >= 3 )) +} + +# Called indirectly by pacman_with_retry. +# shellcheck disable=SC2317,SC2329 +pacman_retry_sleep() { + sleeps=$((sleeps + 1)) + last_sleep="$1" +} + +LLGO_PACMAN_MAX_ATTEMPTS=3 LLGO_PACMAN_RETRY_DELAY_SECONDS=7 \ + pacman_with_retry --noconfirm -S --needed example-package 2>/dev/null +[[ "${attempts}" -eq 3 ]] || fail "pacman attempts = ${attempts}, want 3" +[[ "${sleeps}" -eq 2 ]] || fail "retry sleeps = ${sleeps}, want 2" +[[ "${last_sleep}" == 7 ]] || fail "retry delay = ${last_sleep}, want 7" +[[ " ${last_args[*]} " == *" --noconfirm -S --needed example-package "* ]] || + fail "unexpected pacman arguments: ${last_args[*]}" + +attempts=0 +sleeps=0 +# Called indirectly by pacman_with_retry. +# shellcheck disable=SC2317,SC2329 +pacman_command() { + attempts=$((attempts + 1)) + return 1 +} + +if LLGO_PACMAN_MAX_ATTEMPTS=2 LLGO_PACMAN_RETRY_DELAY_SECONDS=0 \ + pacman_with_retry -U package.pkg.tar.zst 2>/dev/null; then + fail "exhausted pacman retries unexpectedly succeeded" +fi +[[ "${attempts}" -eq 2 ]] || fail "exhausted attempts = ${attempts}, want 2" +[[ "${sleeps}" -eq 1 ]] || fail "exhausted retry sleeps = ${sleeps}, want 1" + +if LLGO_PACMAN_MAX_ATTEMPTS=0 pacman_with_retry -S package 2>/dev/null; then + fail "invalid attempt count unexpectedly succeeded" +fi +if LLGO_PACMAN_RETRY_DELAY_SECONDS=invalid pacman_with_retry -S package 2>/dev/null; then + fail "invalid retry delay unexpectedly succeeded" +fi +if pacman_with_retry 2>/dev/null; then + fail "empty pacman command unexpectedly succeeded" +fi + +echo "MSYS2 pacman retry checks passed" diff --git a/scripts/windows/msvc-target.ps1 b/scripts/windows/msvc-target.ps1 new file mode 100644 index 0000000..9be217f --- /dev/null +++ b/scripts/windows/msvc-target.ps1 @@ -0,0 +1,107 @@ +function Get-LLGoWindowsMSVCTarget { + param( + [Parameter(Mandatory = $true)] + [ValidateSet("386", "amd64", "arm64")] + [string]$GoArch + ) + + switch ($GoArch) { + "386" { + return [PSCustomObject]@{ + GoArch = "386" + Triple = "i686-pc-windows-msvc" + VcpkgTriplet = "x86-windows" + VisualStudio = "x86" + } + } + "amd64" { + return [PSCustomObject]@{ + GoArch = "amd64" + Triple = "x86_64-pc-windows-msvc" + VcpkgTriplet = "x64-windows" + VisualStudio = "x64" + } + } + "arm64" { + return [PSCustomObject]@{ + GoArch = "arm64" + Triple = "aarch64-pc-windows-msvc" + VcpkgTriplet = "arm64-windows" + VisualStudio = "arm64" + } + } + } +} + +function Find-LLGoVisualStudio { + param( + [Parameter(Mandatory = $true)] + [ValidateSet("386", "amd64", "arm64")] + [string]$GoArch + ) + + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $components = @("Microsoft.VisualStudio.Component.VC.Tools.x86.x64") + if ($GoArch -eq "arm64") { + $components += "Microsoft.VisualStudio.Component.VC.Tools.ARM64" + } + # The Windows ARM64 hosted image is moving from VS 2022 to VS 2026. Accept + # either version during the rollout, but keep the Windows 2022 x64 image on 17.x. + $versionRange = if ($env:RUNNER_ARCH -eq "ARM64") { "[17.0,19.0)" } else { "[17.0,18.0)" } + $arguments = @( + "-latest", + "-products", "*", + "-version", $versionRange, + "-property", "installationPath" + ) + foreach ($component in $components) { + $arguments += @("-requires", $component) + } + $installPath = & $vswhere @arguments + if (-not $installPath) { + throw "Visual Studio C++ tools for windows/$GoArch (version $versionRange) were not found" + } + return $installPath +} + +function Enter-LLGoVisualStudio { + param( + [Parameter(Mandatory = $true)] + [ValidateSet("386", "amd64", "arm64")] + [string]$GoArch + ) + + $target = Get-LLGoWindowsMSVCTarget -GoArch $GoArch + $installPath = Find-LLGoVisualStudio -GoArch $GoArch + Import-Module "$installPath\Common7\Tools\Microsoft.VisualStudio.DevShell.dll" + $hostArch = if ($env:RUNNER_ARCH -eq "ARM64") { "arm64" } else { "x64" } + Enter-VsDevShell -VsInstallPath $installPath ` + -SkipAutomaticLocation ` + -DevCmdArguments "-arch=$($target.VisualStudio) -host_arch=$hostArch" + return $target +} + +function Export-LLGoVisualStudioEnvironment { + foreach ($name in @( + "INCLUDE", + "LIB", + "LIBPATH", + "UCRTVersion", + "UniversalCRTSdkDir", + "VCINSTALLDIR", + "VCToolsInstallDir", + "VCToolsVersion", + "WindowsSdkDir", + "WindowsSDKVersion" + )) { + $value = [Environment]::GetEnvironmentVariable($name) + if ($value) { + Add-Content -Encoding utf8 $env:GITHUB_ENV "$name=$value" + } + } + + @("cl.exe", "lib.exe", "link.exe", "dumpbin.exe", "nmake.exe", "rc.exe", "mt.exe") | + ForEach-Object { Split-Path (Get-Command $_).Source } | + Select-Object -Unique | + ForEach-Object { Add-Content -Encoding utf8 $env:GITHUB_PATH $_ } +} diff --git a/scripts/windows/vcpkg/vcpkg.json b/scripts/windows/vcpkg/vcpkg.json new file mode 100644 index 0000000..ba25c3a --- /dev/null +++ b/scripts/windows/vcpkg/vcpkg.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", + "name": "llgo-ci-windows-dependencies", + "version-string": "1", + "builtin-baseline": "ddd0023b0eee70986e42ed49d9d4afb8098f212e", + "dependencies": [ + "bdwgc", + "cjson", + "libffi", + "libuv", + "openssl", + { + "name": "pkgconf", + "host": true + }, + "sqlite3", + "zlib" + ], + "overrides": [ + { + "name": "libffi", + "version": "3.4.6" + } + ] +} diff --git a/src/download.ts b/src/download.ts new file mode 100644 index 0000000..ee1f1a3 --- /dev/null +++ b/src/download.ts @@ -0,0 +1,81 @@ +import { createHash } from 'crypto' +import fs from 'fs' +import https from 'https' +import { pipeline } from 'stream/promises' +import { downloadTimeout, retryNetwork } from './network' + +export async function download( + url: string, + destination: string +): Promise { + await retryNetwork(async () => downloadOnce(url, destination)) +} + +async function downloadOnce( + url: string, + destination: string, + redirects = 0 +): Promise { + const parsed = new URL(url) + const trustedHosts = [ + 'github.com', + 'objects.githubusercontent.com', + 'release-assets.githubusercontent.com' + ] + if ( + parsed.protocol !== 'https:' || + !trustedHosts.includes(parsed.hostname) || + parsed.username || + parsed.password || + redirects > 5 + ) + throw new Error(`Invalid download URL or too many redirects: ${url}`) + const response = await new Promise( + (resolve, reject) => { + const request = https.get( + url, + { headers: { 'User-Agent': 'setup-llgo' } }, + resolve + ) + request.setTimeout(120000, () => request.destroy(downloadTimeout())) + request.on('error', reject) + } + ) + if ( + response.statusCode && + [301, 302, 303, 307, 308].includes(response.statusCode) && + response.headers.location + ) { + response.resume() + await downloadOnce( + new URL(response.headers.location, url).href, + destination, + redirects + 1 + ) + return + } + if (response.statusCode !== 200) { + response.resume() + throw new Error(`Download failed (${response.statusCode}): ${url}`) + } + // Keep the inactivity timeout active while the body is streamed as well. + response.setTimeout(120000, () => response.destroy(downloadTimeout())) + await pipeline(response, fs.createWriteStream(destination)) +} + +export async function verifyChecksum( + archive: string, + filename: string, + checksums: string +): Promise { + const entry = checksums + .split(/\r?\n/) + .find(line => line.trim().split(/\s+/)[1]?.replace(/^\*/, '') === filename) + const expected = entry?.trim().split(/\s+/)[0] + if (!expected || !/^[a-f\d]{64}$/i.test(expected)) + throw new Error(`No SHA-256 checksum for ${filename}`) + const hash = createHash('sha256') + for await (const chunk of fs.createReadStream(archive)) hash.update(chunk) + if (hash.digest('hex') !== expected.toLowerCase()) + throw new Error(`SHA-256 mismatch for ${filename}`) +} diff --git a/src/index.ts b/src/index.ts index f6fdcc4..4949006 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,17 @@ -/** - * The entrypoint for the action. - */ -import { installLLGo } from './install' +import * as core from '@actions/core' +import { installLLGo, prepareLLGo } from './install' async function run(): Promise { - await installLLGo() + try { + if (process.env.SETUP_LLGO_PHASE === 'prepare') await prepareLLGo() + else if (process.env.SETUP_LLGO_PHASE === 'install') + installLLGo( + process.env.SETUP_LLGO_SOURCE || '', + process.env.SETUP_LLGO_METHOD || '' + ) + else throw new Error('Unknown setup-llgo phase') + } catch (error) { + core.setFailed(error instanceof Error ? error.message : String(error)) + } } - -// eslint-disable-next-line @typescript-eslint/no-floating-promises -run() +void run() diff --git a/src/install.ts b/src/install.ts index 2150b13..17ecfd9 100644 --- a/src/install.ts +++ b/src/install.ts @@ -1,180 +1,191 @@ import * as core from '@actions/core' -import * as semver from 'semver' +import { execFileSync } from 'child_process' import fs from 'fs' -import path from 'path' import os from 'os' -import { execSync } from 'child_process' - -const REPO = 'https://github.com/goplus/llgo.git' - -/** - * The main function for the action. - * @returns {Promise} Resolves when the action is complete. - */ -export async function installLLGo(): Promise { - try { - const versionSpec = resolveVersionInput() || '' - const tagVersions = semver.rsort(fetchTags().filter(v => semver.valid(v))) - let version: string | null = null - if (!versionSpec || versionSpec === 'latest') { - version = tagVersions[0] - core.warning( - `No llgo-version specified, using latest version: ${version}` - ) - } else { - version = semver.maxSatisfying(tagVersions, versionSpec) - if (!version) { - core.warning( - `No llgo-version found that satisfies '${versionSpec}', trying branches...` - ) - const branchVersions = fetchBranches() - if (!branchVersions.includes(versionSpec)) { - throw new Error( - `No llgo-version found that satisfies '${versionSpec}' in branches or tags` - ) - } - version = '' - } - } - - let checkoutVersion = '' - if (version) { - core.info(`Selected version ${version} by spec ${versionSpec}`) - checkoutVersion = `v${version}` - core.setOutput('llgo-version-verified', true) - } else { - core.warning( - `Unable to find a version that satisfies the version spec '${versionSpec}', trying branches...` - ) - checkoutVersion = versionSpec - core.setOutput('llgo-version-verified', false) - } - const llgoDir = cloneBranchOrTag(checkoutVersion) - install(llgoDir) - // if (version) { - // checkVersion(version) - // } - core.setOutput('llgo-version', llgoVersion()) - } catch (error) { - // Fail the workflow run if an error occurs - if (error instanceof Error) core.setFailed(error.message) - } +import path from 'path' +import { + parseRemoteRefs, + resolveVersion, + Selection, + versionInput, + verifyInstalledVersion +} from './resolve' +import { platformFor, Platform, releaseAsset, releaseVersion } from './platform' +import { download, verifyChecksum } from './download' +import { retryNetwork } from './network' + +const repository = 'https://github.com/xgo-dev/llgo.git' + +export function archiveTool(): string { + // Git for Windows also ships GNU tar, which treats C: as a remote host + // and cannot unpack ZIPs. Use the native bsdtar explicitly. + return process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') + : 'tar' } - -export function selectVersion( - versions: string[], - versionSpec?: string -): string | null { - const sortedVersions = semver.rsort(versions.filter(v => semver.valid(v))) - if (!versionSpec || versionSpec === 'latest') { - return sortedVersions[0] - } - return semver.maxSatisfying(sortedVersions, versionSpec) +function git(args: string[], cwd?: string): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } + }).trim() } -function cloneBranchOrTag(versionSpec: string): string { - // git clone https://github.com/llgo/llgo.git with tag $versionSpec to $HOME/workdir/llgo - const workDir = path.join(os.homedir(), 'workdir') - if (fs.existsSync(workDir)) { - fs.rmSync(workDir, { recursive: true }) +export function checkoutLLGo( + selected: Selection, + sourceDir: string, + repo = repository +): string { + git(['init', '--quiet'], sourceDir) + git(['remote', 'add', 'origin', repo], sourceDir) + let revision = selected.sha + if (selected.kind === 'commit' && revision.length < 40) { + // A server cannot fetch arbitrary abbreviated object IDs. Resolve against + // fetched history, letting Git reject unknown or ambiguous abbreviations. + git( + [ + 'fetch', + '--quiet', + '--filter=blob:none', + 'origin', + '+refs/heads/*:refs/remotes/origin/*', + '+refs/tags/*:refs/tags/*' + ], + sourceDir + ) + revision = git(['rev-parse', '--verify', `${revision}^{commit}`], sourceDir) + } else { + git(['fetch', '--quiet', '--depth=1', 'origin', revision], sourceDir) } - fs.mkdirSync(workDir) - core.info(`Cloning llgo ${versionSpec} to ${workDir} ...`) - const cmd = `git clone --depth 1 --branch ${versionSpec} ${REPO}` - execSync(cmd, { cwd: workDir, stdio: 'inherit' }) - core.info('llgo cloned') - return path.join(workDir, 'llgo') -} - -function install(llgoDir: string): void { - core.info(`Installing llgo ${llgoDir} ...`) - const bin = path.join(os.homedir(), 'bin') - execSync('go install ./cmd/llgo', { - cwd: llgoDir, - stdio: 'inherit', - env: { - ...process.env, - GOBIN: bin - } - }) - core.addPath(bin) - core.info('llgo installed') -} - -// function checkVersion(versionSpec: string): string { -// core.info(`Testing llgo ${versionSpec} ...`) -// const actualVersion = llgoVersion() -// if (actualVersion !== versionSpec) { -// throw new Error( -// `Installed llgo version ${actualVersion} does not match expected version ${versionSpec}` -// ) -// } -// core.info(`Installed llgo version ${actualVersion}`) -// return actualVersion -// } - -function llgoVersion(): string { - const out = execSync('llgo version', { env: process.env }) - return out.toString().trim().replace(/^v/, '') + if (selected.kind === 'tag') + git(['update-ref', selected.ref, revision], sourceDir) + git(['checkout', '--quiet', '--detach', revision], sourceDir) + return git(['rev-parse', 'HEAD'], sourceDir) } -function fetchTags(): string[] { - const cmd = `git -c versionsort.suffix=- ls-remote --tags --sort=v:refname ${REPO}` - const out = execSync(cmd).toString() - const versions = out - .split('\n') - .filter(s => s) - .map(s => s.split('\t')[1].replace('refs/tags/', '')) - .map(s => s.replace(/^v/, '')) - return versions +interface Release { + assets: { name: string; browser_download_url: string }[] } - -function fetchBranches(): string[] { - const cmd = `git -c versionsort.suffix=- ls-remote --heads --sort=v:refname ${REPO}` - const out = execSync(cmd).toString() - const versions = out - .split('\n') - .filter(s => s) - .map(s => s.split('\t')[1].replace('refs/heads/', '')) - return versions -} - -function resolveVersionInput(): string | undefined { - let version = process.env['INPUT_LLGO_VERSION'] - const versionFilePath = process.env['INPUT_LLGO_VERSION_FILE'] - - if (version && versionFilePath) { - core.warning( - 'Both llgo-version and llgo-version-file inputs are specified, only llgo-version will be used' - ) - } - - if (version) { - return version +export async function installRelease( + selected: Selection, + platform: Platform, + destination: string +): Promise { + if (selected.kind !== 'tag') return false + const tag = selected.ref.slice('refs/tags/'.length) + const headers: Record = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'setup-llgo' } + const token = core.getInput('token') + if (token) headers.Authorization = `Bearer ${token}` + const release = await retryNetwork(async () => { + const response = await fetch( + `https://api.github.com/repos/xgo-dev/llgo/releases/tags/${encodeURIComponent( + tag + )}`, + { headers, signal: AbortSignal.timeout(120000) } + ) + if (response.status === 404) return undefined + if (!response.ok) + throw new Error(`Release lookup failed: HTTP ${response.status}`) + return (await response.json()) as Release + }) + if (!release) return false + const filename = releaseAsset(tag, platform) + const asset = release.assets.find(item => item.name === filename) + if (!asset) return false + const checksumAsset = release.assets.find( + item => item.name === `llgo${releaseVersion(tag)}.checksums.txt` + ) + if (!checksumAsset) + throw new Error(`Release ${tag} is missing its checksum manifest`) + const archive = path.join(destination, filename) + const checksums = path.join(destination, 'checksums.txt') + await download(checksumAsset.browser_download_url, checksums) + await download(asset.browser_download_url, archive) + await verifyChecksum(archive, filename, fs.readFileSync(checksums, 'utf8')) + execFileSync(archiveTool(), ['-xf', archive, '-C', destination], { + stdio: 'inherit' + }) + fs.unlinkSync(archive) + fs.unlinkSync(checksums) + core.info(`Installed release asset ${filename}`) + return true +} - if (versionFilePath) { - if (!fs.existsSync(versionFilePath)) { +export async function prepareLLGo(): Promise { + const platform = platformFor( + process.platform, + core.getInput('architecture') || process.env.RUNNER_ARCH || process.arch, + core.getInput('windows-abi') || 'msvc' + ) + const method = core.getInput('install-method') || 'auto' + if (!['auto', 'source', 'release'].includes(method)) + throw new Error(`Unknown install-method: ${method}`) + const refs = parseRemoteRefs( + git(['ls-remote', '--heads', '--tags', repository]) + ) + const selected = resolveVersion( + versionInput( + core.getInput('llgo-version'), + core.getInput('llgo-version-file') + ), + refs + ) + // Own only a unique temporary directory; never touch the user's ~/workdir. + const sourceDir = fs.mkdtempSync( + path.join(process.env.RUNNER_TEMP || os.tmpdir(), 'setup-llgo-') + ) + try { + const prebuilt = + method !== 'source' && + (await installRelease(selected, platform, sourceDir)) + if (!prebuilt && method === 'release') throw new Error( - `The specified llgo version file at: ${versionFilePath} does not exist` + `No release asset for ${selected.ref} on ${platform.os}/${platform.arch}/${platform.abi}` ) - } - version = parseGopVersionFile(versionFilePath) + const revision = prebuilt ? selected.sha : checkoutLLGo(selected, sourceDir) + core.info( + `Selected ${selected.ref} at ${revision} (${ + prebuilt ? 'release' : 'source' + })` + ) + core.setOutput('install-dir', sourceDir) + core.setOutput('install-method', prebuilt ? 'release' : 'source') + core.setOutput('llgo-revision', revision) + core.setOutput('llgo-ref', selected.ref) + core.setOutput('llgo-version-verified', selected.kind === 'tag') + core.setOutput('architecture', platform.arch) + core.exportVariable( + 'SETUP_LLGO_ACTION_PATH', + process.env.GITHUB_ACTION_PATH || '' + ) + } catch (error) { + fs.rmSync(sourceDir, { recursive: true, force: true }) + throw error } - - return version } -export function parseGopVersionFile(versionFilePath: string): string { - const contents = fs.readFileSync(versionFilePath).toString() - - if ( - path.basename(versionFilePath) === 'go.mod' || - path.basename(versionFilePath) === 'go.work' - ) { - const match = contents.match(/\/\/ llgo (\d+(\.\d+)*)/m) - return match ? match[1] : '' - } - - return contents.trim() +export function installLLGo(sourceDir: string, method: string): void { + if (!sourceDir) throw new Error('The LLGo installation directory is required') + const env = { ...process.env, LLGO_ROOT: sourceDir } + const executable = process.platform === 'win32' ? 'llgo.exe' : 'llgo' + if (method === 'source') + execFileSync('go', ['build', '-o', `bin/${executable}`, './cmd/llgo'], { + cwd: sourceDir, + env, + stdio: 'inherit' + }) + const binary = path.join(sourceDir, 'bin', executable) + const version = execFileSync(binary, ['version'], { + env, + encoding: 'utf8' + }).trim() + if (method === 'release') + verifyInstalledVersion(version, process.env.SETUP_LLGO_REF || '') + core.exportVariable('LLGO_ROOT', sourceDir) + core.addPath(path.dirname(binary)) + core.info(version) + core.setOutput('llgo-version', version) } diff --git a/src/network.ts b/src/network.ts new file mode 100644 index 0000000..d719269 --- /dev/null +++ b/src/network.ts @@ -0,0 +1,44 @@ +import * as core from '@actions/core' + +function isTransientNetworkError(error: unknown): boolean { + for (let depth = 0; error instanceof Error && depth < 3; depth++) { + if ( + error.name === 'TimeoutError' || + [ + 'ECONNRESET', + 'ECONNREFUSED', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'ENOTFOUND', + 'ENETUNREACH', + 'EHOSTUNREACH', + 'ERR_STREAM_PREMATURE_CLOSE', + 'UND_ERR_SOCKET', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_BODY_TIMEOUT' + ].includes((error as Error & { code?: string }).code || '') + ) + return true + error = error.cause + } + return false +} + +export async function retryNetwork(operation: () => Promise): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await operation() + } catch (error) { + // HTTP responses, bad checksums and filesystem errors are not transient + // transport failures. In particular, never retry a missing release. + if (attempt === 2 || !isTransientNetworkError(error)) throw error + core.info(`Network transfer failed; retrying (attempt ${attempt + 2}/3)`) + await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt)) + } + } +} + +export function downloadTimeout(): Error { + return Object.assign(new Error('Download timed out'), { code: 'ETIMEDOUT' }) +} diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 0000000..5bf4d12 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,39 @@ +export interface Platform { + os: string + arch: string + abi: string +} + +export function platformFor( + os: string, + architecture: string, + abi: string +): Platform { + const arch = + ( + { x64: 'amd64', x86_64: 'amd64', aarch64: 'arm64' } as Record< + string, + string + > + )[architecture.toLowerCase()] || architecture.toLowerCase() + if (!['linux', 'darwin', 'win32'].includes(os)) + throw new Error(`Unsupported OS: ${os}`) + if (!['amd64', 'arm64'].includes(arch)) + throw new Error(`Unsupported architecture: ${architecture}`) + if (os === 'win32' && !['msvc', 'mingw'].includes(abi)) + throw new Error(`Unsupported Windows ABI: ${abi}`) + return { + os: os === 'win32' ? 'windows' : os, + arch, + abi: os === 'win32' ? abi : '' + } +} + +export function releaseVersion(tag: string): string { + return tag.replace(/^v/, '') +} + +export function releaseAsset(tag: string, platform: Platform): string { + const suffix = platform.os === 'windows' ? `-${platform.abi}.zip` : '.tar.gz' + return `llgo${releaseVersion(tag)}.${platform.os}-${platform.arch}${suffix}` +} diff --git a/src/resolve.ts b/src/resolve.ts new file mode 100644 index 0000000..46c39a1 --- /dev/null +++ b/src/resolve.ts @@ -0,0 +1,107 @@ +import * as semver from 'semver' +import fs from 'fs' +import path from 'path' + +export interface RemoteRef { + name: string + sha: string + kind: 'tag' | 'branch' +} +export interface Selection { + ref: string + sha: string + kind: 'tag' | 'branch' | 'commit' +} + +export function parseRemoteRefs(output: string): RemoteRef[] { + const refs = new Map() + const peeled = new Map() + for (const line of output.split('\n')) { + const [sha, ref] = line.trim().split(/\s+/) + if (!sha || !ref) continue + if (ref.endsWith('^{}')) peeled.set(ref.slice(0, -3), sha) + else if (ref.startsWith('refs/tags/')) + refs.set(ref, { name: ref.slice(10), sha, kind: 'tag' }) + else if (ref.startsWith('refs/heads/')) + refs.set(ref, { name: ref.slice(11), sha, kind: 'branch' }) + } + for (const [ref, sha] of peeled) { + const tag = refs.get(ref) + if (tag) tag.sha = sha + } + return [...refs.values()] +} + +function selected(ref: RemoteRef): Selection { + return { + ref: `refs/${ref.kind === 'tag' ? 'tags' : 'heads'}/${ref.name}`, + sha: ref.sha, + kind: ref.kind + } +} +function glob(pattern: string, name: string): boolean { + const escaped = pattern.replace(/[|\\{}()[\]^$+?.*]/g, char => { + if (char === '*') return '.*' + if (char === '?') return '.' + return `\\${char}` + }) + return new RegExp(`^${escaped}$`).test(name) +} + +export function resolveVersion(input: string, refs: RemoteRef[]): Selection { + const spec = input.trim() || 'latest' + const tags = refs.filter(ref => ref.kind === 'tag') + const branches = refs.filter(ref => ref.kind === 'branch') + if (spec.startsWith('refs/')) { + const exact = refs.find(ref => selected(ref).ref === spec) + if (!exact) throw new Error(`Unknown LLGo ref: ${spec}`) + return selected(exact) + } + const exactTag = tags.find( + ref => ref.name === spec || ref.name === `v${spec}` + ) + if (exactTag) return selected(exactTag) + const exactBranch = branches.find(ref => ref.name === spec) + if (exactBranch) return selected(exactBranch) + if (/^[a-f\d]{7,40}$/i.test(spec)) + return { kind: 'commit', ref: spec, sha: spec.toLowerCase() } + const range = semver.validRange(spec === 'latest' ? '*' : spec) + const matches = + range !== null + ? tags.filter( + ref => semver.valid(ref.name) && semver.satisfies(ref.name, range) + ) + : tags.filter(ref => glob(spec, ref.name)) + const versions = matches.filter(ref => semver.valid(ref.name)) + versions.sort((a, b) => semver.rcompare(a.name, b.name)) + if (versions.length) return selected(versions[0]) + if (matches.length === 1) return selected(matches[0]) + if (matches.length > 1) throw new Error(`Ambiguous LLGo tag pattern: ${spec}`) + const branchMatches = branches.filter(ref => glob(spec, ref.name)) + if (branchMatches.length === 1) return selected(branchMatches[0]) + if (branchMatches.length > 1) + throw new Error(`Ambiguous LLGo branch pattern: ${spec}`) + throw new Error(`No LLGo version, tag, branch or commit matches: ${spec}`) +} + +export function versionInput(explicit: string, file: string): string { + if (explicit.trim()) return explicit.trim() + if (!file) return '' + const contents = fs.readFileSync(file, 'utf8').trim() + if (['go.mod', 'go.work'].includes(path.basename(file))) { + const match = contents.match(/^\s*\/\/\s*llgo\s+(.+?)\s*$/m) + if (!match) throw new Error(`No // llgo version directive in ${file}`) + return match[1] + } + if (!contents) throw new Error(`Empty LLGo version file: ${file}`) + return contents +} + +export function verifyInstalledVersion(actual: string, ref: string): void { + if (!ref.startsWith('refs/tags/')) return + const expected = semver.valid(ref.slice('refs/tags/'.length)) + if (!expected) return + const reported = actual.match(/^llgo v?(\S+)\s/) + if (!reported || reported[1] !== expected) + throw new Error(`Installed LLGo version ${actual} does not match ${ref}`) +} diff --git a/testdata/smoke/go.mod b/testdata/smoke/go.mod new file mode 100644 index 0000000..e4750d4 --- /dev/null +++ b/testdata/smoke/go.mod @@ -0,0 +1,3 @@ +module setup-llgo-smoke + +go 1.27.0 diff --git a/testdata/smoke/main.go b/testdata/smoke/main.go new file mode 100644 index 0000000..62b852e --- /dev/null +++ b/testdata/smoke/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func sum(a, b int) int { return a + b } + +func main() { fmt.Println(sum(19, 23)) } diff --git a/testdata/smoke/main_test.go b/testdata/smoke/main_test.go new file mode 100644 index 0000000..e73240d --- /dev/null +++ b/testdata/smoke/main_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +func TestSum(t *testing.T) { + if got := sum(19, 23); got != 42 { + t.Fatalf("sum = %d, want 42", got) + } +}