Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/manifest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Generate Manifest

on:
push:
branches: [ main ]
paths:
- 'Translator/translations/**/*.json'
workflow_dispatch:

permissions:
contents: write

jobs:
manifest-generation:
runs-on: ubuntu-latest
env:
CI: true

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x

- name: Build solution
run: dotnet build --configuration Release

- name: Generate manifest.json
run: dotnet run -- generate-manifest
working-directory: Translator

- name: Commit
uses: EndBug/add-and-commit@v10
with:
default_author: github_actor
add: ./manifest.json # if the working directory is the repo root dir
message: "Update manifest.json"
commit: ""
push: true
Comment on lines +36 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What do GitHub’s official security hardening guidelines recommend for pinning third-party actions (tag vs full-length commit SHA)?

💡 Result:

GitHub's official security hardening guidelines recommend pinning third-party actions to a full-length commit SHA as the most secure option. This is the only way to use an action as an immutable release, mitigating risks from malicious updates since it requires a SHA-1 collision to compromise. They warn against short SHAs due to ambiguity risks from forks. Pinning to a tag is acceptable only if you fully trust the creator (e.g., verified badge on Marketplace), but tags are mutable and can be moved or deleted by repository access compromise. GitHub provides repository/organization policies to enforce full-length SHA pinning.

Citations:


🏁 Script executed:

# Check if the file exists and verify the content at lines 41-47
if [ -f .github/workflows/manifest.yml ]; then
  echo "=== File exists. Showing lines 35-50 for context ==="
  sed -n '35,50p' .github/workflows/manifest.yml
else
  echo "File not found at .github/workflows/manifest.yml"
  # Try to find it
  find . -name "manifest.yml" -type f 2>/dev/null | head -20
fi

Repository: btcpayserver/btcpayserver-translator

Length of output: 547


🌐 Web query:

What is the current commit SHA for EndBug/add-and-commit v10 release on GitHub

💡 Result:

The current commit SHA for the EndBug/add-and-commit v10 release (v10.0.0) on GitHub is 290ea2c423ad77ca9c62ae0f5b224379612c0321. This is confirmed as the commit tagged with v10.0.0 on March 22, 2026, which bumps the version from 9.1.4 to 10.0.0 in package.json and package-lock.json. There is also a tag "v10" pointing to the same commit. No newer v10 releases exist as of 2026-04-29.

Citations:


🏁 Script executed:

# Search for all 'uses:' directives in the manifest.yml file
grep -n "uses:" .github/workflows/manifest.yml

Repository: btcpayserver/btcpayserver-translator

Length of output: 201


Pin third-party GitHub Action by commit SHA to reduce supply-chain risk.

On Line 41, EndBug/add-and-commit@v10 uses a mutable tag. Pin to a full-length commit SHA per GitHub's official security hardening guidelines, which recommend this as the only way to use an action as an immutable release.

Proposed fix
-        uses: EndBug/add-and-commit@v10
+        uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/manifest.yml around lines 41 - 47, Replace the mutable tag
"uses: EndBug/add-and-commit@v10" with an immutable full-length commit SHA for
the EndBug/add-and-commit action; locate the uses line (uses:
EndBug/add-and-commit@v10) and update it to the corresponding full commit SHA
(e.g., uses: EndBug/add-and-commit@<full-commit-sha>) so the workflow is pinned
to an exact commit per GitHub security guidelines, and verify the SHA matches
the intended release before committing.

315 changes: 315 additions & 0 deletions Translator.Tests/Services/ManifestGeneratorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
using System.Security.Cryptography;
using System.Text.Json;
using BTCPayTranslator.Models;
using BTCPayTranslator.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;

namespace BTCPayTranslator.Tests.Services;

public class ManifestGeneratorTests
{
[Fact]
public async Task GenerateManifest_WritesManifest_ForValidTranslationFile()
{
var tempDir = CreateTempDirectory();
var translationsDir = Path.Combine(tempDir, "translations");
Directory.CreateDirectory(translationsDir);
var translationFile = Path.Combine(translationsDir, "French.json");
var manifestPath = Path.Combine(tempDir, "manifest.json");

try
{
await File.WriteAllTextAsync(translationFile, """
{
"_maintainer": "alice|https://github.com/alice",
"hello": "bonjour"
}
""");

var sut = CreateSut();
var result = await sut.GenerateManifest(translationsDir, manifestPath);

Assert.True(result);
Assert.True(File.Exists(manifestPath));

var manifest = await ReadManifest(manifestPath);
var entry = Assert.Single(manifest.Languages);

Assert.Equal("fr", entry.Code);
Assert.Equal("fr-FR", entry.Bcp47);
Assert.Equal("French", entry.Name);
Assert.Equal("Français", entry.Native);
Assert.Equal("translations/French.json", entry.File);
Assert.Equal("alice|https://github.com/alice", entry.Maintainer);
Assert.Equal(ComputeSha256(translationFile), entry.Sha);
Assert.Matches("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", entry.Updated);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}

[Fact]
public async Task GenerateManifest_ReturnsFalse_WhenNoTranslationFilesExist()
{
var tempDir = CreateTempDirectory();
var manifestPath = Path.Combine(tempDir, "manifest.json");

try
{
var sut = CreateSut();

var result = await sut.GenerateManifest(tempDir, manifestPath);

Assert.False(result);
Assert.False(File.Exists(manifestPath));
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}

[Fact]
public async Task GenerateManifest_ReturnsFalse_WhenTranslationDirectoryDoesNotExist()
{
var translationsDir = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"));
var manifestPath = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"), "manifest.json");
var sut = CreateSut();

var result = await sut.GenerateManifest(translationsDir, manifestPath);

Assert.False(result);
}

[Fact]
public async Task GenerateManifest_RetainsUpdated_WhenExistingShaMatches()
{
var tempDir = CreateTempDirectory();
var translationsDir = Path.Combine(tempDir, "translations");
Directory.CreateDirectory(translationsDir);
var translationFile = Path.Combine(translationsDir, "French.json");
var manifestPath = Path.Combine(tempDir, "manifest.json");

try
{
await File.WriteAllTextAsync(translationFile, """
{
"_maintainer": "alice|https://github.com/alice",
"hello": "bonjour"
}
""");

var existingSha = ComputeSha256(translationFile);
var expectedUpdated = "2024-01-02T03:04:05Z";
var existingManifest = new Manifest(
new List<ManifestEntry>
{
new(
Code: "fr",
Bcp47: "fr-FR",
Name: "French",
Native: "Français",
File: "translations/French.json",
Sha: existingSha,
Maintainer: "old",
Updated: expectedUpdated)
},
Redirect: null);

await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(existingManifest));

var sut = CreateSut();
var result = await sut.GenerateManifest(translationsDir, manifestPath);

Assert.True(result);
var generated = await ReadManifest(manifestPath);
var entry = Assert.Single(generated.Languages);
Assert.Equal(expectedUpdated, entry.Updated);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}

[Fact]
public async Task GenerateManifest_UpdatesUpdated_WhenExistingShaDiffers()
{
var tempDir = CreateTempDirectory();
var translationsDir = Path.Combine(tempDir, "translations");
Directory.CreateDirectory(translationsDir);
var translationFile = Path.Combine(translationsDir, "French.json");
var manifestPath = Path.Combine(tempDir, "manifest.json");

try
{
await File.WriteAllTextAsync(translationFile, """
{
"_maintainer": "alice|https://github.com/alice",
"hello": "bonjour"
}
""");

var previousUpdated = "2024-01-02T03:04:05Z";
var existingManifest = new Manifest(
new List<ManifestEntry>
{
new(
Code: "fr",
Bcp47: "fr-FR",
Name: "French",
Native: "Français",
File: "translations/French.json",
Sha: "deadbeef",
Maintainer: "old",
Updated: previousUpdated)
},
Redirect: null);

await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(existingManifest));

var sut = CreateSut();
var result = await sut.GenerateManifest(translationsDir, manifestPath);

Assert.True(result);
var generated = await ReadManifest(manifestPath);
var entry = Assert.Single(generated.Languages);

Assert.NotEqual(previousUpdated, entry.Updated);
Assert.Matches("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", entry.Updated);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}

[Fact]
public async Task GenerateManifest_SetsMaintainerToNull_WhenFieldMissing()
{
var tempDir = CreateTempDirectory();
var translationFile = Path.Combine(tempDir, "French.json");
var manifestPath = Path.Combine(tempDir, "manifest.json");

try
{
await File.WriteAllTextAsync(translationFile, """
{
"hello": "bonjour"
}
""");

var sut = CreateSut();
var result = await sut.GenerateManifest(tempDir, manifestPath);

Assert.True(result);
var manifest = await ReadManifest(manifestPath);
var entry = Assert.Single(manifest.Languages);
Assert.Null(entry.Maintainer);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}

[Fact]
public async Task GenerateManifest_ReturnsFalse_WhenLanguageNameMappingIsMissing()
{
var tempDir = CreateTempDirectory();
var translationFile = Path.Combine(tempDir, "Klingon.json");
var manifestPath = Path.Combine(tempDir, "manifest.json");

try
{
await File.WriteAllTextAsync(translationFile, """
{
"_maintainer": "alice|https://github.com/alice",
"hello": "nuqneH"
}
""");

var sut = CreateSut();
var result = await sut.GenerateManifest(tempDir, manifestPath);

Assert.False(result);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}

[Fact]
public async Task GenerateManifest_ReturnsFalse_WhenTranslationFileHasInvalidJson()
{
var tempDir = CreateTempDirectory();
var translationFile = Path.Combine(tempDir, "French.json");
var manifestPath = Path.Combine(tempDir, "manifest.json");

try
{
await File.WriteAllTextAsync(translationFile, "{\"_maintainer\":");

var sut = CreateSut();
var result = await sut.GenerateManifest(tempDir, manifestPath);

Assert.False(result);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}

private static ManifestGenerator CreateSut()
{
return new ManifestGenerator(NullLogger<ManifestGenerator>.Instance);
}

private static async Task<Manifest> ReadManifest(string path)
{
var json = await File.ReadAllTextAsync(path);
var manifest = JsonSerializer.Deserialize<Manifest>(json);
return Assert.IsType<Manifest>(manifest);
}

private static string ComputeSha256(string path)
{
using var sha256 = SHA256.Create();
using var stream = File.OpenRead(path);
var hash = sha256.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}

private static string CreateTempDirectory()
{
var directory = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
return directory;
}
}
12 changes: 12 additions & 0 deletions Translator/Models/LanguageInfo.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace BTCPayTranslator.Models;

Expand Down Expand Up @@ -119,4 +121,14 @@ public static IEnumerable<LanguageInfo> GetAllLanguages()
{
return Languages.Values;
}

public static (string Code, LanguageInfo)? GetLanguageInfoByName(string name)
{
var match = Languages.FirstOrDefault(kvp =>
kvp.Value.Name.Equals(name, StringComparison.OrdinalIgnoreCase));

if (match.Key == null) return null;

return (match.Key, match.Value);
}
}
Loading
Loading