chore: more restructuring of the repo. Most significant change is rename from flakes-action to ci-os

This commit is contained in:
Tommy 2024-11-12 10:12:42 +01:00
parent d535b2ba6e
commit df7e141133
Signed by: tommy
SSH key fingerprint: SHA256:1LWgQT3QPHIT29plS8jjXc3S1FcE/4oGvsx3Efxs6Uc
20 changed files with 1035 additions and 296 deletions

2
.gitignore vendored
View file

@ -1,2 +1,4 @@
secrets.env
apps/lix-builder/result
apps/ci-os/dist/test
apps/ci-os/result

View file

@ -1,12 +1,12 @@
version: "3"
tasks:
build-flakesaction:
build-ci-os:
desc: Builds and pushes the flakes action image
dir: "{{ .ROOT_DIR }}/apps/lix-builder"
cmds:
- nix build .#packages.x86_64-linux.flakes-action && nerdctl load < result
&& nerdctl push code.252.no/tommy/flakes-action:latest
- nix build .#packages.x86_64-linux.build-image && nerdctl load < result &&
nerdctl push code.252.no/tommy/ci-os:latest
create-image:
desc: Build local docker image (nixos-builder)

View file

@ -1,4 +1,4 @@
## Flakes Action
## CI-OS
This container is a little special since it provides a Nix flake designed to generate a docker image to use with
[Forgejo runners](https://code.forgejo.org/forgejo/runner). It packages essential tools and helper programs to streamline
@ -14,30 +14,31 @@ We try to reproduce much-used GitHub actions into one package to avoid calling a
### Docker Image Info
The latest container resulting from the nix build, is located in the registry at `code.252.no/tommy/flakes-action:latest` and includes:
The latest container resulting from the nix build, is located in the registry at `code.252.no/tommy/ci-os:latest` and includes:
- **Nix Environment**: Pre-configured with Nix and essential configurations.
- **Helper Programs**: Bundles `flux-local`, `flux-diff`, and `forgejo-comment`.
- **Helper Programs**: Bundles `flux-local`, `flux-diff`, `forgejo-comment` and more.
- **Essential Build Tools**: Includes utilities like `git`, `docker`, `bash`, `curl`, `jq`, and more.
### Nix Flake Info
- **Apps**: Accessible via `nix run` or `nix shell` commands.
- **Packages**: Builds the `flakes-action` Docker image named `flakes-action` with necessary tools.
- **Packages**: Builds the `ci-os` Docker image named `ci-os` with necessary tools.
### Helper Programs Provided
The flake provides the following applications:
| Application | Description | External Ref |
|-------------------|-----------------------------------------------------------------|---------------------------------------------------------------------|
| `git-sv` | Semantic versioning tool for git based on conventional commits. | [tommy/git-sv](https://code.252.no/tommy/git-sv)
| `flux-local` | Tool for performing local Flux operations and diffs. | [allenporter/flux-local](https://github.com/allenporter/flux-local) |
| `flux-diff` | Utility to diff Flux resources locally. | [buroa/k8s-gitops](https://github.com/buroa/k8s-gitops/blob/master/.github/workflows/flux-diff.yaml)
| `forgejo-comment` | Script to post comments on Forgejo merge requests. | -
| Application | Description | External Ref |
|-------------------|-----------------------------------------------------------------|------------------------------------------------------------------------------------------------------|
| `git-sv` | Semantic versioning tool for git based on conventional commits. | [tommy/git-sv](https://code.252.no/tommy/git-sv) |
| `flux-local` | Tool for performing local Flux operations and diffs. | [allenporter/flux-local](https://github.com/allenporter/flux-local) |
| `flux-diff` | Utility to diff Flux resources locally. | [buroa/k8s-gitops](https://github.com/buroa/k8s-gitops/blob/master/.github/workflows/flux-diff.yaml) |
| `forgejo-comment` | Script to post comments on Forgejo merge requests. | - |
| `forgejo-release` | Script to create releases in Forgejo. | - |
Other standard packages are provided from nixpkgs. For an up-to-date list have a look at `flakes-action` in
Other standard packages are provided from nixpkgs. For an up-to-date list have a look at `ci-os` in
[flake.nix](./flake.nix).
@ -45,13 +46,13 @@ Other standard packages are provided from nixpkgs. For an up-to-date list have a
#### Building
We provide an example taskfile in `task docker:build-flakesaction` which is used for manual builds at `code.252.no`. The task uses
We provide an example taskfile in `task docker:build-ci-os` which is used for manual builds at `code.252.no`. The task uses
`nerdctl`, but you may replace this with `docker` or `podman`.
```bash
nix build .#packages.x86_64-linux.flakes-action # build image
nix build .#packages.x86_64-linux.ci-os # build image
nerdctl load < result # loads nix build result (tar archive)
nerdctl push code.252.no/tommy/flakes-action:latest # push to registry
nerdctl push code.252.no/tommy/ci-os:latest # push to registry
```
#### In Runner
@ -82,7 +83,7 @@ To use the Docker image in your Forgejo runner add it to your Helm values (this
- "--instance"
- $(FORGEJO_INSTANCE_URL)
- "--labels"
- "flakes-action:docker://code.252.no/tommy/flakes-action:latest,[...]"
- "ci-os:docker://code.252.no/tommy/ci-os:latest,[...]"
env:
- name: RUNNER_TOKEN
valueFrom:

View file

@ -1,5 +1,5 @@
{
description = "docker base images";
description = "CI-OS - the Continuous Integration OS";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
@ -25,23 +25,34 @@
flux-local = flake-utils.lib.mkApp { drv = pkgs-local.flux-local; };
flux-diff = flake-utils.lib.mkApp { drv = pkgs-local.flux-diff; };
forgejo-comment = flake-utils.lib.mkApp { drv = pkgs-local.forgejo-comment; };
forgejo-release = flake-utils.lib.mkApp { drv = pkgs-local.forgejo-release; };
};
packages = {
flakes-action = pkgs.dockerTools.buildImageWithNixDb {
name = "code.252.no/tommy/flakes-action";
build-image = pkgs.dockerTools.buildImageWithNixDb {
name = "code.252.no/tommy/ci-os";
tag = "latest";
copyToRoot = pkgs.buildEnv {
name = "image-root";
pathsToLink = ["/bin" "/etc"];
ignoreCollisions = true;
paths = with pkgs; [
gitSvPkg
# kubernetes
chart-testing
kubernetes-helm
kubernetes-polaris
pluto
pkgs-local.flux-local
pkgs-local.flux-diff
pkgs-local.forgejo-comment
pkgs-local.forgejo-release
findutils
# repository tooling
gitSvPkg
gnupg
coreutils-full
findutils
python312Full
docker
bash

View file

@ -7,4 +7,5 @@ in
flux-local = fluxLocal;
flux-diff = import ./flux-diff { inherit pkgs lib fluxLocal; };
forgejo-comment = import ./forgejo-comment { inherit lib pkgs; };
forgejo-release = import ./forgejo-release { inherit lib pkgs; };
}

View file

@ -0,0 +1,13 @@
{ lib, pkgs }:
pkgs.buildGoModule rec {
pname = "forgejo-comment";
version = "1.0.0";
src = ./src;
# Vendor dependencies for reproducibility
vendorHash = null;
subPackages = [ "." ];
}

View file

@ -0,0 +1,142 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strconv"
)
func main() {
// Define command-line flags
var forgejoAPIURL string
var token string
var repoOwner string
var repoName string
var issueId int
flag.StringVar(&forgejoAPIURL, "forgejo-api-url", "https://code.252.no/api/v1", "Forgejo API URL")
flag.StringVar(&token, "token", "", "Forgejo API token")
flag.StringVar(&repoOwner, "repo-owner", "", "Repository owner")
flag.StringVar(&repoName, "repo-name", "", "Repository name")
flag.IntVar(&issueId, "issue-id", 0, "Issue id")
flag.Parse()
// Get the diff file from the positional arguments
args := flag.Args()
if len(args) < 1 {
fmt.Println("Usage: forgejo-comment <diff-file>")
os.Exit(1)
}
diffFile := args[0]
// Read environment variables if flags are not set
if forgejoAPIURL == "" {
forgejoAPIURL = os.Getenv("FORGEJO_API_URL")
if forgejoAPIURL == "" {
fmt.Println("Error: FORGEJO_API_URL is not set")
os.Exit(1)
}
}
if token == "" {
token = os.Getenv("FORGEJO_TOKEN")
if token == "" {
fmt.Println("Error: FORGEJO_TOKEN is not set")
os.Exit(1)
}
}
if repoOwner == "" {
repoOwner = os.Getenv("REPO_OWNER")
if repoOwner == "" {
fmt.Println("Error: REPO_OWNER is not set")
os.Exit(1)
}
}
if repoName == "" {
repoName = os.Getenv("REPO_NAME")
if repoName == "" {
fmt.Println("Error: REPO_NAME is not set")
os.Exit(1)
}
}
if issueId == 0 {
issueIdEnv := os.Getenv("ISSUE_ID")
if issueIdEnv == "" {
fmt.Println("Error: ISSUE_ID is not set")
os.Exit(1)
}
var err error
issueId, err = strconv.Atoi(issueIdEnv)
if err != nil {
fmt.Println("Error: Invalid ISSUE_ID")
os.Exit(1)
}
}
// Read the diff file
diffContentBytes, err := os.ReadFile(diffFile)
if err != nil {
fmt.Printf("Error reading diff file: %v\n", err)
os.Exit(1)
}
// Prepare the comment content
diffContent := string(diffContentBytes)
commentBody := fmt.Sprintf("```diff\n%s\n```", diffContent)
// Prepare the request payload
payload := map[string]string{
"body": commentBody,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
fmt.Printf("Error marshaling payload: %v\n", err)
os.Exit(1)
}
// Construct the API URL
apiURL := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", forgejoAPIURL, repoOwner, repoName, issueId)
// Create the HTTP request
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
if err != nil {
fmt.Printf("Error creating request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
req.Header.Set("Content-Type", "application/json")
// Send the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Error sending request: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
// Read the response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response: %v\n", err)
os.Exit(1)
}
// Check the response status code
if resp.StatusCode != 201 {
fmt.Printf("Failed to post comment. HTTP status: %d\n", resp.StatusCode)
fmt.Printf("Response body: %s\n", string(respBody))
os.Exit(1)
} else {
fmt.Printf("Posted diff to Forgejo issue #%d\n", issueId)
}
}

View file

@ -0,0 +1,13 @@
{ lib, pkgs }:
pkgs.buildGoModule rec {
pname = "forgejo-release";
version = "1.0.0";
src = ./src;
# Vendor dependencies for reproducibility
vendorHash = null;
subPackages = [ "." ];
}

View file

@ -0,0 +1,3 @@
module code.252.no/tommy/forgejo-release
go 1.22.6

View file

@ -0,0 +1,826 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// API endpoint templates
const (
tagURLTemplate = "%s/api/v1/repos/%s/tags/%s"
tagsURLTemplate = "%s/api/v1/repos/%s/tags"
releasesURLTemplate = "%s/api/v1/repos/%s/releases"
releaseByTagURLTemplate = "%s/api/v1/repos/%s/releases/tags/%s"
releaseAssetsURLTemplate = "%s/api/v1/repos/%s/releases/%d/assets"
releaseURLTemplate = "%s/api/v1/repos/%s/releases/%d"
latestReleaseURLTemplate = "%s/api/v1/repos/%s/releases/latest"
)
// Release represents a Forgejo release.
type Release struct {
ID int64 `json:"id"`
Draft bool `json:"draft"`
Tag string `json:"tag_name"`
Assets []struct {
ID int64 `json:"id"`
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
func main() {
// Parse command-line flags and environment variables.
config, err := parseConfig()
if err != nil {
fmt.Printf("Configuration error: %v\n", err)
os.Exit(1)
}
// Execute the specified command.
switch config.Command {
case "create":
err := createReleaseCommand(config)
if err != nil {
fmt.Printf("Release creation failed: %v\n", err)
os.Exit(1)
}
case "download":
err := downloadRelease(config)
if err != nil {
fmt.Printf("Download failed: %v\n", err)
os.Exit(1)
}
default:
fmt.Println("Unknown command. Use 'create' or 'download'.")
os.Exit(1)
}
}
// Config holds the configuration parameters.
type Config struct {
ForgejoURL string
Repo string
Title string
ReleaseDir string
DownloadLatest bool
TmpDir string
Override bool
Retry int
Delay int
GPGPrivateKey string
GPGPassphrase string
Tag string
SHA string
Token string
Prerelease bool
Command string
Interactive bool
}
// parseConfig parses command-line flags and environment variables.
func parseConfig() (*Config, error) {
var config Config
// Check if the program is run without arguments (interactive mode).
if len(os.Args) == 1 {
config.Interactive = true
interactiveSetup(&config)
return &config, nil
}
flag.StringVar(&config.ForgejoURL, "forgejo-url", getEnv("FORGEJO", "https://code.252.no"), "Forgejo instance URL")
flag.StringVar(&config.Repo, "repo", getEnv("REPO", ""), "Repository in the format owner/name (required)")
flag.StringVar(&config.Title, "title", getEnv("TITLE", ""), "Release title (defaults to tag)")
flag.StringVar(&config.ReleaseDir, "release-dir", getEnv("RELEASE_DIR", "dist/"), "Directory containing release assets")
flag.BoolVar(&config.DownloadLatest, "download-latest", getEnvBool("DOWNLOAD_LATEST", false), "Download the latest release")
flag.StringVar(&config.TmpDir, "tmp-dir", getEnv("TMP_DIR", ""), "Temporary directory")
flag.BoolVar(&config.Override, "override", getEnvBool("OVERRIDE", false), "Override existing release and tag")
flag.IntVar(&config.Retry, "retry", getEnvInt("RETRY", 1), "Number of retries when downloading")
flag.IntVar(&config.Delay, "delay", getEnvInt("DELAY", 10), "Delay between retries in seconds")
flag.StringVar(&config.GPGPrivateKey, "gpg-private-key", getEnv("GPG_PRIVATE_KEY", ""), "Path to GPG private key")
flag.StringVar(&config.GPGPassphrase, "gpg-passphrase", getEnv("GPG_PASSPHRASE", ""), "Path to GPG passphrase file")
flag.StringVar(&config.Tag, "tag", getEnv("TAG", ""), "Release tag (required for create)")
flag.StringVar(&config.SHA, "sha", getEnv("SHA", ""), "Commit SHA for the tag (optional)")
flag.StringVar(&config.Token, "token", getEnv("TOKEN", ""), "Forgejo API token (required)")
flag.BoolVar(&config.Prerelease, "prerelease", getEnvBool("PRERELEASE", false), "Mark the release as a prerelease")
flag.Usage = func() {
fmt.Printf("Usage: %s <create|download> [options]\n", os.Args[0])
fmt.Println("\nCommands:")
fmt.Println(" create Create a new release with uploaded assets")
fmt.Println(" download Download assets from an existing release")
fmt.Println("\nOptions:")
flag.PrintDefaults()
fmt.Println("\nExamples:")
fmt.Printf(" %s create --repo=owner/repo --tag=v1.0.0 --token=your-token --release-dir=path/to/assets\n", os.Args[0])
fmt.Printf(" %s download --repo=owner/repo --tag=v1.0.0 --token=your-token --release-dir=path/to/download\n", os.Args[0])
}
flag.Parse()
// Get the command (create or download).
if len(flag.Args()) < 1 {
flag.Usage()
os.Exit(1)
}
config.Command = flag.Args()[0]
// Validate required parameters.
if config.Repo == "" {
return nil, errors.New("repository (--repo) is required")
}
if config.Token == "" {
return nil, errors.New("API token (--token) is required")
}
if config.Command == "create" && config.Tag == "" {
return nil, errors.New("tag (--tag) is required for creating a release")
}
if config.Command == "download" && !config.DownloadLatest && config.Tag == "" {
return nil, errors.New("tag (--tag) is required unless --download-latest is true")
}
return &config, nil
}
// interactiveSetup prompts the user for configuration in interactive mode.
func interactiveSetup(config *Config) {
reader := bufio.NewReader(os.Stdin)
fmt.Println("Interactive Mode: Please provide the following details.")
config.Command = promptChoice(reader, "Choose a command (create/download): ", []string{"create", "download"}, "create")
config.ForgejoURL = prompt(reader, "Forgejo instance URL", getEnv("FORGEJO", "https://code.252.no"))
config.Repo = prompt(reader, "Repository (owner/name)", "")
config.Token = prompt(reader, "Forgejo API token", "")
if config.Command == "create" {
config.ReleaseDir = prompt(reader, "Directory containing release assets", getEnv("RELEASE_DIR", "dist/"))
config.Tag = prompt(reader, "Release tag", "")
config.Title = prompt(reader, "Release title", config.Tag)
config.SHA = prompt(reader, "Commit SHA for the tag (leave blank if tag exists)", "")
config.Prerelease = promptBool(reader, "Is this a prerelease?", false)
config.Override = promptBool(reader, "Override existing release and tag if they exist?", false)
config.GPGPrivateKey = prompt(reader, "Path to GPG private key (optional)", "")
config.GPGPassphrase = prompt(reader, "Path to GPG passphrase file (optional)", "")
} else if config.Command == "download" {
config.ReleaseDir = prompt(reader, "Directory to download assets into", getEnv("RELEASE_DIR", "dist/"))
config.DownloadLatest = promptBool(reader, "Download the latest release?", false)
if !config.DownloadLatest {
config.Tag = prompt(reader, "Release tag to download", "")
}
config.Retry = promptInt(reader, "Number of retries if release is not available", 1)
config.Delay = promptInt(reader, "Delay between retries in seconds", 10)
}
}
// Helper functions for interactive prompts.
func prompt(reader *bufio.Reader, label, defaultValue string) string {
if defaultValue != "" {
fmt.Printf("%s [%s]: ", label, defaultValue)
} else {
fmt.Printf("%s: ", label)
}
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
return defaultValue
}
return input
}
func promptBool(reader *bufio.Reader, label string, defaultValue bool) bool {
defaultStr := "no"
if defaultValue {
defaultStr = "yes"
}
for {
fmt.Printf("%s [%s]: ", label, defaultStr)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(strings.ToLower(input))
if input == "" {
return defaultValue
}
if input == "yes" || input == "y" {
return true
}
if input == "no" || input == "n" {
return false
}
fmt.Println("Please enter 'yes' or 'no'.")
}
}
func promptInt(reader *bufio.Reader, label string, defaultValue int) int {
for {
fmt.Printf("%s [%d]: ", label, defaultValue)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
return defaultValue
}
value, err := strconv.Atoi(input)
if err == nil {
return value
}
fmt.Println("Please enter a valid integer.")
}
}
func promptChoice(reader *bufio.Reader, label string, choices []string, defaultValue string) string {
choiceMap := make(map[string]bool)
for _, choice := range choices {
choiceMap[choice] = true
}
for {
fmt.Printf("%s [%s]: ", label, defaultValue)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(strings.ToLower(input))
if input == "" {
return defaultValue
}
if _, valid := choiceMap[input]; valid {
return input
}
fmt.Printf("Invalid choice. Valid options are: %s\n", strings.Join(choices, ", "))
}
}
// createReleaseCommand handles the creation of a release.
func createReleaseCommand(config *Config) error {
// Create temporary directory if not provided.
if config.TmpDir == "" {
var err error
config.TmpDir, err = os.MkdirTemp("", "forgejo-release")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(config.TmpDir)
}
// Override existing release and tag if requested.
if config.Override {
if err := deleteRelease(config); err != nil {
return err
}
if err := deleteTag(config); err != nil {
return err
}
}
// Ensure the tag exists.
if err := ensureTag(config); err != nil {
return fmt.Errorf("failed to ensure tag: %w", err)
}
// Sign release assets if GPG key is provided.
if config.GPGPrivateKey != "" {
if err := signReleaseAssets(config); err != nil {
return fmt.Errorf("failed to sign assets: %w", err)
}
}
// Create and publish the release.
if err := createAndPublishRelease(config); err != nil {
return fmt.Errorf("failed to create release: %w", err)
}
fmt.Printf("Release '%s' created successfully.\n", config.Tag)
return nil
}
// uploadRelease handles the upload of a release.
func uploadRelease(config *Config) error {
// Create temporary directory if not provided.
if config.TmpDir == "" {
var err error
config.TmpDir, err = os.MkdirTemp("", "forgejo-release")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(config.TmpDir)
}
// Override existing release and tag if requested.
if config.Override {
if err := deleteRelease(config); err != nil {
return err
}
if err := deleteTag(config); err != nil {
return err
}
}
// Ensure the tag exists.
if err := ensureTag(config); err != nil {
return fmt.Errorf("failed to ensure tag: %w", err)
}
// Sign release assets if GPG key is provided.
if config.GPGPrivateKey != "" {
if err := signReleaseAssets(config); err != nil {
return fmt.Errorf("failed to sign assets: %w", err)
}
}
// Create and publish the release.
if err := createAndPublishRelease(config); err != nil {
return fmt.Errorf("failed to create release: %w", err)
}
return nil
}
// downloadRelease handles the download of a release.
func downloadRelease(config *Config) error {
// Create temporary directory if not provided.
if config.TmpDir == "" {
var err error
config.TmpDir, err = os.MkdirTemp("", "forgejo-release")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(config.TmpDir)
}
// Retrieve the release information.
release, err := getRelease(config)
if err != nil {
return err
}
// Download the assets.
if err := downloadAssets(config, release); err != nil {
return fmt.Errorf("failed to download assets: %w", err)
}
return nil
}
// ensureTag checks if the tag exists and creates it if it doesn't.
func ensureTag(config *Config) error {
tagSHA, err := getTagSHA(config)
if err != nil {
if errors.Is(err, errTagNotFound) {
// Tag does not exist; create it if SHA is provided.
if config.SHA == "" {
return errors.New("tag does not exist and SHA is not provided to create it")
}
if err := createTag(config); err != nil {
return fmt.Errorf("failed to create tag: %w", err)
}
fmt.Printf("Tag '%s' created successfully.\n", config.Tag)
} else {
return err
}
} else {
// Tag exists; verify SHA if provided.
if config.SHA != "" && config.SHA != tagSHA {
return errors.New("tag SHA does not match the provided SHA")
}
// Use the tag's SHA if not provided.
if config.SHA == "" {
config.SHA = tagSHA
}
fmt.Printf("Tag '%s' already exists.\n", config.Tag)
}
return nil
}
// getTagSHA retrieves the SHA associated with the tag.
func getTagSHA(config *Config) (string, error) {
apiURL := fmt.Sprintf(tagURLTemplate, config.ForgejoURL, config.Repo, config.Tag)
resp, err := apiRequest("GET", apiURL, nil, config.Token)
if err != nil {
return "", fmt.Errorf("failed to get tag: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var result struct {
Commit struct {
SHA string `json:"sha"`
} `json:"commit"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode tag response: %w", err)
}
return result.Commit.SHA, nil
} else if resp.StatusCode == http.StatusNotFound {
return "", errTagNotFound
}
bodyBytes, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("failed to get tag: %s", string(bodyBytes))
}
var errTagNotFound = errors.New("tag not found")
// createTag creates a new tag in the repository.
func createTag(config *Config) error {
payload := map[string]string{
"tag_name": config.Tag,
"target": config.SHA,
}
payloadBytes, _ := json.Marshal(payload)
apiURL := fmt.Sprintf(tagsURLTemplate, config.ForgejoURL, config.Repo)
resp, err := apiRequest("POST", apiURL, bytes.NewBuffer(payloadBytes), config.Token)
if err != nil {
return fmt.Errorf("failed to create tag: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to create tag: %s", string(bodyBytes))
}
return nil
}
// createAndPublishRelease creates a new release and uploads assets.
func createAndPublishRelease(config *Config) error {
// Create the release.
releaseID, err := createRelease(config)
if err != nil {
return err
}
// Upload assets.
if err := uploadAssets(config, releaseID); err != nil {
return err
}
// Publish the release.
if err := updateReleaseDraftState(config, releaseID, false); err != nil {
return err
}
return nil
}
// createRelease creates a new release and returns its ID.
func createRelease(config *Config) (int64, error) {
isPrerelease := config.Prerelease || strings.Contains(strings.ToLower(config.Tag), "-rc")
title := config.Title
if title == "" {
title = config.Tag
}
payload := map[string]interface{}{
"tag_name": config.Tag,
"title": title,
"prerelease": isPrerelease,
"draft": true,
"body": "",
}
payloadBytes, _ := json.Marshal(payload)
apiURL := fmt.Sprintf(releasesURLTemplate, config.ForgejoURL, config.Repo)
resp, err := apiRequest("POST", apiURL, bytes.NewBuffer(payloadBytes), config.Token)
if err != nil {
return 0, fmt.Errorf("failed to create release: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return 0, fmt.Errorf("failed to create release: %s", string(bodyBytes))
}
var release Release
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return 0, fmt.Errorf("failed to decode release response: %w", err)
}
return release.ID, nil
}
// uploadAssets uploads the assets to the release.
func uploadAssets(config *Config, releaseID int64) error {
entries, err := os.ReadDir(config.ReleaseDir)
if err != nil {
return fmt.Errorf("failed to read release directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
filePath := filepath.Join(config.ReleaseDir, entry.Name())
if err := uploadAsset(config, releaseID, filePath); err != nil {
return fmt.Errorf("failed to upload asset %s: %w", entry.Name(), err)
}
}
return nil
}
// uploadAsset uploads a single asset to the release.
func uploadAsset(config *Config, releaseID int64, filePath string) error {
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open asset file: %w", err)
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return fmt.Errorf("failed to get file info: %w", err)
}
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("attachment", fileInfo.Name())
if err != nil {
return fmt.Errorf("failed to create form file: %w", err)
}
if _, err := io.Copy(part, file); err != nil {
return fmt.Errorf("failed to copy file content: %w", err)
}
if err := writer.Close(); err != nil {
return fmt.Errorf("failed to close multipart writer: %w", err)
}
apiURL := fmt.Sprintf(releaseAssetsURLTemplate, config.ForgejoURL, config.Repo, releaseID)
req, err := http.NewRequest("POST", apiURL, body)
if err != nil {
return fmt.Errorf("failed to create upload request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", fmt.Sprintf("token %s", config.Token))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to upload asset: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to upload asset: %s", string(bodyBytes))
}
return nil
}
// updateReleaseDraftState updates the draft state of the release.
func updateReleaseDraftState(config *Config, releaseID int64, state bool) error {
payload := map[string]bool{
"draft": state,
}
payloadBytes, _ := json.Marshal(payload)
apiURL := fmt.Sprintf(releaseURLTemplate, config.ForgejoURL, config.Repo, releaseID)
resp, err := apiRequest("PATCH", apiURL, bytes.NewBuffer(payloadBytes), config.Token)
if err != nil {
return fmt.Errorf("failed to update release draft state: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to update release draft state: %s", string(bodyBytes))
}
return nil
}
// signReleaseAssets signs the release assets using GPG.
func signReleaseAssets(config *Config) error {
gpgHome, err := os.MkdirTemp("", "gpg")
if err != nil {
return fmt.Errorf("failed to create GPG home directory: %w", err)
}
defer os.RemoveAll(gpgHome)
// Import GPG private key.
gpgArgs := []string{"--batch", "--yes", "--import", config.GPGPrivateKey}
if config.GPGPassphrase != "" {
gpgArgs = append([]string{"--passphrase-file", config.GPGPassphrase}, gpgArgs...)
}
cmd := exec.Command("gpg", gpgArgs...)
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to import GPG key: %w, output: %s", err, string(output))
}
// Sign each asset.
entries, err := os.ReadDir(config.ReleaseDir)
if err != nil {
return fmt.Errorf("failed to read release directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || strings.HasSuffix(entry.Name(), ".sha256") {
continue
}
filePath := filepath.Join(config.ReleaseDir, entry.Name())
sigPath := filePath + ".asc"
cmdArgs := []string{"--armor", "--detach-sign", "--output", sigPath, filePath}
if config.GPGPassphrase != "" {
cmdArgs = append([]string{"--batch", "--yes", "--passphrase-file", config.GPGPassphrase}, cmdArgs...)
}
cmd = exec.Command("gpg", cmdArgs...)
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to sign asset %s: %w, output: %s", entry.Name(), err, string(output))
}
}
return nil
}
// deleteRelease deletes an existing release by tag.
func deleteRelease(config *Config) error {
apiURL := fmt.Sprintf(releaseByTagURLTemplate, config.ForgejoURL, config.Repo, config.Tag)
resp, err := apiRequest("DELETE", apiURL, nil, config.Token)
if err != nil {
return fmt.Errorf("failed to delete release: %w", err)
}
resp.Body.Close()
return nil
}
// deleteTag deletes an existing tag.
func deleteTag(config *Config) error {
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/tags/%s", config.ForgejoURL, config.Repo, config.Tag)
resp, err := apiRequest("DELETE", apiURL, nil, config.Token)
if err != nil {
return fmt.Errorf("failed to delete tag: %w", err)
}
resp.Body.Close()
return nil
}
// getRelease retrieves the release information.
func getRelease(config *Config) (*Release, error) {
var release *Release
var err error
for i := 0; i < config.Retry; i++ {
if config.DownloadLatest {
release, err = getLatestRelease(config)
} else {
release, err = getReleaseByTag(config)
}
if err == nil && !release.Draft {
break
}
fmt.Printf("Release not ready yet, retrying in %d seconds...\n", config.Delay)
time.Sleep(time.Duration(config.Delay) * time.Second)
}
if release == nil || release.Draft {
return nil, errors.New("release not available")
}
return release, nil
}
// getLatestRelease retrieves the latest release.
func getLatestRelease(config *Config) (*Release, error) {
apiURL := fmt.Sprintf(latestReleaseURLTemplate, config.ForgejoURL, config.Repo)
resp, err := apiRequest("GET", apiURL, nil, config.Token)
if err != nil {
return nil, fmt.Errorf("failed to get latest release: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to get latest release: %s", string(bodyBytes))
}
var release Release
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("failed to decode latest release: %w", err)
}
return &release, nil
}
// getReleaseByTag retrieves a release by its tag.
func getReleaseByTag(config *Config) (*Release, error) {
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/releases/tags/%s", config.ForgejoURL, config.Repo, config.Tag)
resp, err := apiRequest("GET", apiURL, nil, config.Token)
if err != nil {
return nil, fmt.Errorf("failed to get release by tag: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to get release by tag: %s", string(bodyBytes))
}
var release Release
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("failed to decode release by tag: %w", err)
}
return &release, nil
}
func downloadAssets(config *Config, release *Release) error {
if err := os.MkdirAll(config.ReleaseDir, 0755); err != nil {
return fmt.Errorf("failed to create release directory: %w", err)
}
for _, asset := range release.Assets {
assetPath := filepath.Join(config.ReleaseDir, asset.Name)
if err := downloadAsset(config, asset.BrowserDownloadURL, assetPath); err != nil {
return fmt.Errorf("failed to download asset %s: %w", asset.Name, err)
}
fmt.Printf("Downloaded asset: %s\n", asset.Name)
}
fmt.Println("All assets downloaded successfully.")
return nil
}
// downloadAsset downloads a single asset.
func downloadAsset(config *Config, url, filepath string) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", config.Token))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download asset: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to download asset: %s", string(bodyBytes))
}
outFile, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer outFile.Close()
if _, err := io.Copy(outFile, resp.Body); err != nil {
return fmt.Errorf("failed to write asset to file: %w", err)
}
return nil
}
// apiRequest sends an HTTP request to the Forgejo API.
func apiRequest(method, url string, body io.Reader, token string) (*http.Response, error) {
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, fmt.Errorf("failed to create API request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("API request failed: %w", err)
}
return resp, nil
}
// Helper functions to get environment variables with defaults.
func getEnv(key, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
if valueStr, exists := os.LookupEnv(key); exists {
if value, err := strconv.Atoi(valueStr); err == nil {
return value
}
}
return defaultValue
}
func getEnvBool(key string, defaultValue bool) bool {
if valueStr, exists := os.LookupEnv(key); exists {
if value, err := strconv.ParseBool(valueStr); err == nil {
return value
}
}
return defaultValue
}

View file

@ -1,102 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: builds
labels:
pod-security.kubernetes.io/enforce: privileged
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/warn: privileged
pod-security.kubernetes.io/warn-version: latest
pod-security.kubernetes.io/audit: privileged
pod-security.kubernetes.io/audit-version: latest
---
apiVersion: v1
kind: Pod
metadata:
name: kaniko
namespace: builds
spec:
securityContext:
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
seccompProfile:
type: Unconfined
containers:
- name: kaniko
image: code.252.no/tommy/kaniko:v24.10.01@sha256:d51c3b5c468bb070108d9e27884072f8527f20c9e41e2133621c56f62f89afc0
resources:
limits:
cpu: 1000m
memory: 2Gi
command: ["/opt/kaniko/kaniko"]
args:
- --dockerfile=Dockerfile
#- --reproducible
- --context=/kaniko
- --custom-platform=linux/amd64
- --destination=code.252.no/tommy/lix-builder:v24.10.01
#- --dockerfile=Dockerfile
#- --reproducible
#- --kaniko-dir=/workspace/kaniko
#- --context=/workspace
#- --custom-platform=linux/amd64
#- --destination=code.252.no/tommy/lix-builder:v24.10.01
#- --cache=true
#- --compressed-caching=false
#- --use-new-run
#- --cleanup
volumeMounts:
# - name: workspace-dir
# mountPath: /workspace
- name: docker-config
mountPath: /opt/kaniko/.docker/config.json
subPath: config.json
- name: dockerfile
mountPath: /kaniko/Dockerfile
subPath: Dockerfile
securityContext:
runAsUser: 1001
privileged: false
allowPrivilegeEscalation: false
runAsNonRoot: true
capabilities:
drop:
- ALL
add:
- CHOWN
- FOWNER
- DAC_OVERRIDE
- SYS_ADMIN
restartPolicy: Never
volumes:
# - name: workspace-dir
# emptyDir: {}
- name: docker-config
secret:
secretName: tommy-pushsecret-rw
items:
- key: .dockerconfigjson
path: config.json
- name: dockerfile
configMap:
name: dockerfile
---
apiVersion: v1
kind: ConfigMap
metadata:
name: dockerfile
namespace: builds
data:
Dockerfile: |
FROM ghcr.io/lix-project/lix:2.91
WORKDIR /tmp/working-dir
RUN nix-env -iA nixpkgs.go nixpkgs.vim nixpkgs.sops nixpkgs.nix-direnv \
nixpkgs.attic-client nixpkgs.nh nixpkgs.deploy-rs nixpkgs.statix \
nixpkgs.deadnix nixpkgs.alejandra nixpkgs.home-manager \
nixpkgs.ssh-to-age nixpkgs.gnupg nixpkgs.age nixpkgs.linux \
nixpkgs.go-task nixpkgs.curl nixpkgs.fish nixpkgs.nixos-anywhere
ENTRYPOINT ["fish"]

View file

@ -1,9 +0,0 @@
app: lix-builder
version: v24.10.01
channels:
- name: stable
platforms: ["linux/amd64"]
stable: false
tests:
enabled: true
type: cli

View file

@ -1,20 +0,0 @@
{ lib, pkgs }:
pkgs.buildGoModule rec {
pname = "forgejo-comment";
version = "1.0.0";
src = ./src;
# Vendor dependencies for reproducibility
vendorHash = null;
subPackages = [ "." ];
meta = {
homepage = "https://code.252.no/tommy/containers";
description = "Comment on Forgejo issues via the Forgejo API";
license = licenses.mit;
maintainers = with maintainers; [ "tommy-skaug" ];
};
}

View file

@ -1,142 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"net/http"
"os"
"strconv"
)
func main() {
// Define command-line flags
var forgejoAPIURL string
var token string
var repoOwner string
var repoName string
var issueIndex int
flag.StringVar(&forgejoAPIURL, "forgejo-api-url", "", "Forgejo API URL")
flag.StringVar(&token, "token", "", "Forgejo API token")
flag.StringVar(&repoOwner, "repo-owner", "", "Repository owner")
flag.StringVar(&repoName, "repo-name", "", "Repository name")
flag.IntVar(&issueIndex, "issue-index", 0, "Issue index")
flag.Parse()
// Get the diff file from the positional arguments
args := flag.Args()
if len(args) < 1 {
fmt.Println("Usage: forgejo-comment <diff-file>")
os.Exit(1)
}
diffFile := args[0]
// Read environment variables if flags are not set
if forgejoAPIURL == "" {
forgejoAPIURL = os.Getenv("FORGEJO_API_URL")
if forgejoAPIURL == "" {
fmt.Println("Error: FORGEJO_API_URL is not set")
os.Exit(1)
}
}
if token == "" {
token = os.Getenv("FORGEJO_TOKEN")
if token == "" {
fmt.Println("Error: FORGEJO_TOKEN is not set")
os.Exit(1)
}
}
if repoOwner == "" {
repoOwner = os.Getenv("REPO_OWNER")
if repoOwner == "" {
fmt.Println("Error: REPO_OWNER is not set")
os.Exit(1)
}
}
if repoName == "" {
repoName = os.Getenv("REPO_NAME")
if repoName == "" {
fmt.Println("Error: REPO_NAME is not set")
os.Exit(1)
}
}
if issueIndex == 0 {
issueIndexEnv := os.Getenv("ISSUE_INDEX")
if issueIndexEnv == "" {
fmt.Println("Error: ISSUE_INDEX is not set")
os.Exit(1)
}
var err error
issueIndex, err = strconv.Atoi(issueIndexEnv)
if err != nil {
fmt.Println("Error: Invalid ISSUE_INDEX")
os.Exit(1)
}
}
// Read the diff file
diffContentBytes, err := ioutil.ReadFile(diffFile)
if err != nil {
fmt.Printf("Error reading diff file: %v\n", err)
os.Exit(1)
}
// Prepare the comment content
diffContent := string(diffContentBytes)
commentBody := fmt.Sprintf("```diff\n%s\n```", diffContent)
// Prepare the request payload
payload := map[string]string{
"body": commentBody,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
fmt.Printf("Error marshaling payload: %v\n", err)
os.Exit(1)
}
// Construct the API URL
apiURL := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", forgejoAPIURL, repoOwner, repoName, issueIndex)
// Create the HTTP request
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
if err != nil {
fmt.Printf("Error creating request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
req.Header.Set("Content-Type", "application/json")
// Send the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Error sending request: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
// Read the response body
respBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading response: %v\n", err)
os.Exit(1)
}
// Check the response status code
if resp.StatusCode != 201 {
fmt.Printf("Failed to post comment. HTTP status: %d\n", resp.StatusCode)
fmt.Printf("Response body: %s\n", string(respBody))
os.Exit(1)
} else {
fmt.Printf("Posted diff to Forgejo issue #%d\n", issueIndex)
}
}